Skip to content

adopt-project.py: _default_branch reads origin/HEAD and HEAD by full name and whole, and reads the source only at a branch of its own, so a git clone -b source is no longer git-failed with no remedy, and a HEAD on origin/x, on main<U+00A0> or beside a tag main no longer adopts another branch's commit (#797) - #830

Merged
brettheap merged 5 commits into
mainfrom
fix/797-adopt-default-branch-refs
Oct 18, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-1 (openRepoShape-1)
Claim: #797 (comment)

What

adopt-project.py's _default_branch names the branch plan, check and execute read the source at. It asked git symbolic-ref --short of refs/remotes/origin/HEAD, then of HEAD, stripped the decoded name, took origin/ off whichever answered, and Source._at then ran git rev-parse <name>. Five things went wrong (#457's L1-68, L1-69 and L1-71, and one more measured beside them):

  • L1-71, case 2: a remote default branch with no local copy. After git clone -b dev, origin/HEAD names main and only dev is local. plan, plan --tracking-branch dev, and check and execute of a plan made before a local main was deleted, all ended REFUSED git-failed: git rev-parse main … ambiguous argument 'main', exit 2, in git's words, with no remedy.
  • L1-68: a HEAD on main<U+00A0> beside a branch main. The strip took the U+00A0 off, and plan exited 0 recording main and the OTHER branch's commit.
  • L1-69: a HEAD on a local branch named origin/x beside a branch x. origin/ came off HEAD's name too, and plan exited 0 recording x and its commit.
  • A tag named like the branch. With a tag main, HEAD's short name was heads/main: plan recorded tracking_branch: heads/main, and check found it plan-tracking-branch-missing. Read from origin/HEAD, the name was main, and git rev-parse main answered with the TAG's commit, which plan recorded without a word.
  • L1-71, case 1 (rider): a default branch outside the alphabet a command line may carry, such as café. It is refused unsafe-value, rightly, with only the pattern as its remedy, though a way past it works.

Now:

Why (repro on main)

Measured on main 24a977c0 and on this branch's head, git 2.43.0, Python 3.12.3, local sources, a stub gh, GIT_CONFIG_GLOBAL=/dev/null, --local-remote-dir. Each source is a few files in one commit, plus the commits a row names. The seed for the clone rows has main, and dev one commit ahead of it.

source and command main this branch
git clone -b dev: plan exit 2, REFUSED git-failed: git rev-parse main … ambiguous argument 'main', no remedy exit 2, REFUSED source-default-branch-not-local (below), no plan file
the same: plan --tracking-branch dev exit 2, the same git-failed exit 0: default_branch: dev, dev's commit, tracking_branch: dev
that plan: check, then execute no plan exit 0 plan ok; exit 0 adoption verified
a clone on main, planned, then main deleted with dev checked out: check exit 2, the same git-failed exit 2, REFUSED source-default-branch-not-local
the same: execute exit 2, the same git-failed exit 2, the same refusal, no leg
the same after git branch main origin/main: check exit 0, plan ok exit 0, plan ok
a clone of master, renamed main on the remote and in the clone and pruned, origin/HEAD still on master: plan exit 2, git-failed, git rev-parse master, no remedy exit 2, the new refusal, with git remote set-head origin --auto and no git branch master origin/master, which cannot run there
HEAD on main<U+00A0>, a commit ahead of main: plan exit 0, default_branch: main and main's commit exit 2, REFUSED unsafe-value: --tracking-branch (not given, so the source's default branch) is 'main\xa0', with the way past it
the same: plan --tracking-branch main exit 0, default_branch: main and main's commit exit 0, default_branch: "main\xa0" and ITS commit, tracking_branch: main
HEAD on a branch origin/x, a commit ahead of x: plan exit 0, default_branch: x, x's commit, tracking_branch: x exit 0, default_branch: origin/x, its commit, tracking_branch: origin/x
HEAD on main, a tag main a commit behind: plan, then check exit 0 recording heads/main as both branches; check exit 1, plan-tracking-branch-missing exit 0, main and the branch's commit; check exit 0
the same, with origin/HEAD naming origin/main: plan exit 0, default_branch: main and the TAG's commit exit 0, main and the branch's commit
detached HEAD, no origin/HEAD, only branch trunk, a tag refs/heads/main a commit behind: plan exit 2, git-failed, git rev-parse main exit 2, REFUSED source-default-branch-not-local naming main, nothing read at the tag
an orphan HEAD on new, beside a branch named refs/heads/new: plan exit 2, git-failed, git rev-parse new exit 2, the new refusal naming new
detached HEAD, no origin/HEAD, only branch trunk: plan --tracking-branch trunk exit 2, git-failed, git rev-parse main exit 0, read at trunk
origin/HEAD set to refs/remotes/upstream/main, HEAD on feature: plan exit 0, default_branch: upstream/main, that ref's commit, and only a NOTE that upstream/main is no branch of the source exit 0, default_branch: feature, its commit, and a NOTE naming refs/remotes/upstream/main
origin/HEAD naming origin/ma<U+2028>in, which the source has no branch of: plan exit 2, git-failed, no remedy exit 2, the new refusal, the name spelled ma\u2028in throughout, two lines for str.splitlines
only branch café: plan exit 2, unsafe-value, the pattern as the only remedy exit 2, unsafe-value, with the way past it (below)
the same after git branch main: plan --tracking-branch main exit 0 exit 0, default_branch: "café", tracking_branch: main
HEAD on main (control): plan exit 0 exit 0, the same plan values

The new refusal, as plan prints it for the git clone -b dev source (check and execute print the same):

REFUSED source-default-branch-not-local: the source's `origin/HEAD` names `main`, the default branch its remote `origin` had when it was last asked, and the source has no branch `main` of its own (its branches: `dev`). A clone made with `git clone -b <branch>` has none, nor does one whose own was deleted, and the source is read at a branch of its own, never at another nobody named in its place
Remediation: make it a branch of the source's own, from its remote's: `git branch main origin/main`, run in the source; where `origin` may have changed its default branch since, run `git remote set-head origin --auto` first. Or run `plan` with `--tracking-branch` naming a branch the source has, and the source is read at that branch; `check` and `execute` then read it at that branch while the source has no branch `main` of its own.

Where origin has no origin/<b>, the first sentence of the remediation is instead: "its remote origin has no branch <b> either, as the source last fetched it: ask origin for its default branch again, git remote set-head origin --auto, run in the source." Where --tracking-branch names no branch of the source either, the detail adds , nor one named <it> before the branch list. For a detached HEAD:

REFUSED source-default-branch-not-local: the source names no branch of its own to read: it has no `origin/HEAD` naming a branch of its remote `origin`, its HEAD is detached, and it has no branch `main`, which this tool reads then (its branches: `trunk`). The source is read at a branch of its own, never at another nobody named in its place
Remediation: check out the branch to adopt, in the source (`git switch <branch>`). Or run `plan` with `--tracking-branch` naming a branch the source has, and the source is read at that branch; `check` and `execute` then read it at that branch while the source has no branch `main` of its own.

The café refusal's remediation now ends:

… the rest must match ^[A-Za-z0-9._/@+~-]{1,255}$. The source's branch can keep its name: the source is read at it as it is, and only the tracking branch is held to that pattern, since `execute` pushes both legs to it and opens the split's pull request against it. So make a branch whose name matches at the same commit, in the source with that branch checked out (`git branch main`, and `git push origin main` where the source has a remote `origin`), and pass it: `--tracking-branch main`.

The push is needed for a clone: check reads the branches of the source's remote origin (#479), so a main made only in the clone is plan-tracking-branch-missing until it is pushed, and plan ok after (probed).

How

All in adopt-project.py; scripts/repo_shape.py (pinned) is unchanged.

  • _symbolic_ref(path, ref) reads git symbolic-ref -q <ref> as bytes, removes one trailing \n, and decodes with surrogateescape; _branch_under(name, prefix) takes off exactly prefix, or gives None.
  • _branch_names(path, prefix) reads git for-each-ref --format=%(refname) <prefix> as bytes, each name decoded as _branches_of decodes them (_git_path), so membership is exact for every name git keeps, adopt-project.py _default_branch reads the source's branch name through git_out, which decodes strictly, so a source whose HEAD (or origin/HEAD) names a branch that is not UTF-8 ends plan, check and execute in a UnicodeDecodeError traceback, exit 1, from Source.open, with no plan written and no refusal naming the branch #425's caf\udce9 included. BRANCH_REFS (the existing refs/heads/) is the prefix for the source's own branches, and _branches_of now uses it too.
  • _default_branch(path, instead=None): origin/HEAD's branch, else HEAD's, else main. That branch where it is one of the source's own; else instead where that is; else _no_branch_of_its_own refuses it, naming what named it (origin/HEAD, an orphan HEAD, or the guess for a detached HEAD), with the ways through; _from_the_remote words the first way for origin/HEAD's branch: git branch <b> origin/<b> only where refs/remotes/origin/<b> exists and the name is printable, the same in words for a name that is not, and git remote set-head origin --auto where it does not exist.
  • Source._at reads the commit with git show-ref --verify --hash refs/heads/<branch> (still through _git_out_exact, adopt-project.py under LC_ALL=C PYTHONUTF8=0: the --description em dash, plan's git grep and check's submodule url still end in a UnicodeEncodeError before any leg #653). Source.open, in_place and clone pass instead through.
  • plan passes --tracking-branch as instead, as given; it is only compared with the source's branch names here and is checked as before (unsafe-value, adopt-bad-tracking-branch) once the source is open. check and execute pass the plan's source.default_branch (Plan.read_at), which plan records as the branch it read the source at.
  • _checked_default_branch wraps checked_value (pinned) for the default branch only, and adds DEFAULT_BRANCH_WAY_PAST to its remediation. A --tracking-branch the person passed is refused as before, word for word.
  • _branches_named: the "its branches: …" list _tracking_branch_problem built, factored out so the new refusal names branches the same way. The finding's text is unchanged.

One behaviour changes beside the issue's rows, probed and not a test here: a --source that is no git repository is still git-failed, now naming git for-each-ref … refs/heads/ where it named git rev-parse main.

Tests

21 new cases, each failing on main:

file before → after new
tests/test_adopt_tracking_branch.py 9 → 17 the clone -b dev refusal and its first way through; --tracking-branch dev and check; check after main is deleted; execute adopting at dev (needs git-filter-repo); a renamed remote default, asked again with git remote set-head origin --auto; detached and orphan HEAD read at --tracking-branch; the café way past it, followed
tests/test_adopt_branch_names.py 22 → 29 HEAD on origin/x; a tag main, from HEAD and from origin/HEAD; a tag refs/heads/main, a ref refs/remotes/refs/heads/main and a branch refs/heads/new, none read for the missing branch; an origin/HEAD on refs/remotes/upstream/main, named in a NOTE
tests/test_adopt_git_reads.py 31 → 37 main<U+00A0> (written "main\u00a0") as HEAD and as origin/HEAD: refused when not given, read whole with --tracking-branch main; the way through for ma<U+2028>in and #425's caf<E9>, spelled, in two lines

tests/test_adopt_git_reads.py's #425 helpers with_default_branch and planned_past take the branch name as an argument, defaulting to #425's own, and #425's tests pass unchanged. tests/test_adopt_c_locale_bytes.py's gone case, a clone whose café was deleted, now ends in source-default-branch-not-local, said alike under LC_ALL=C PYTHONUTF8=0 and in UTF-8 mode; only its two docstrings change.

Revert table, at this head: each change reverted alone in adopt-project.py, against the 21 new cases.

reverted new cases failed
all of it (main's adopt-project.py) 21 of 21
all of the review's fixes (the previous head 0a369bba) 9: the three refs named refs/heads/<b>, the renamed remote default, the NOTE, the two spelled names, and the two whose refusal text changed
the name read as main read it (--short, stripped, origin/ off either) 7: origin/x, the tag from HEAD, the NOTE, the four main<U+00A0>
strip() put back after decoding 4: the four main<U+00A0>
origin/ taken off HEAD's branch too 1: origin/x
git rev-parse <branch> by short name 2: the tag from HEAD and from origin/HEAD
git rev-parse refs/heads/<branch> in place of show-ref --verify 0: with the next row's fix in place, the name is always a branch of the source, and rev-parse takes that ref first
a name the source has no branch of returned for a detached or orphan HEAD 3: the three refs named refs/heads/<b> (refused git-failed by show-ref, not by name)
both of the last two 3: the same, now read at the other ref's commit, exit 0
origin/HEAD's branch taken with no local copy 7: the clone -b dev refusal, --tracking-branch dev, check after the deletion, execute at dev, the renamed remote default, the two spelled names
instead ignored 4: --tracking-branch dev, execute at dev, detached and orphan HEAD
instead for origin/HEAD's branch only 2: detached and orphan HEAD
check and execute passing no instead (Plan.read_at None) 4: --tracking-branch dev (its check), execute at dev, detached and orphan HEAD (their check)
git branch <b> origin/<b> printed for any name 2: the two spelled names
git branch <b> origin/<b> offered where origin/<b> is gone 1: the renamed remote default
the detail's "the default branch of its remote origin" 3: the clone -b dev refusal, check after the deletion, the renamed remote default
the promise "check and execute then read it where the plan did" 1: the clone -b dev refusal
no NOTE for an origin/HEAD outside origin 1: the NOTE
the café way past it dropped 1: the café case
a silent fallback to HEAD's branch in place of the refusal (a mutant) 5: the clone -b dev refusal, check after the deletion, the renamed remote default, the two spelled names

Targeted runs at c2bd75ce (main 24a977c0 merged in), clean tree before and after, a stub gh first on PATH, a bogus GH_TOKEN, an empty GH_CONFIG_DIR, /usr/bin/python3 -m pytest -o tmp_path_retention_policy=failed, over 97 files: the 96 that grep -l -E 'adopt-project|adopt_project|\bADOPT\b' tests/*.py finds (tests/test_shape_doctor.py and tests/test_scaffold_leg_path_collisions.py among them) and tests/test_cli_reference.py, in four shards per mode:

mode result
git-filter-repo on PATH 6884 passed, 13 skipped, 1 xfailed
git-filter-repo hidden (PATH=/usr/local/bin:/usr/bin:/bin after the stub dir) 6504 passed, 393 skipped, 1 xfailed

Each is the previous head's gate (0a369bba: 6877 passed, and 6497 passed with 393 skipped) plus the 7 new cases, with the same skips.

Windows

CI is Linux only (#597). The new tests create branches named dev, trunk, new, x, origin/x, main, feature, refs/heads/new, café, main<U+00A0> and ma<U+2028>in, the tags main and refs/heads/main, and the refs refs/remotes/refs/heads/main and refs/remotes/upstream/main, all legal on NTFS, and read every plan back with load_yaml. Nothing compares "…\n" in bytes of a file written with write_text. #425's byte-0xE9 cases skip where git cannot name such a branch, as before. Not run on Windows.

Not in this PR

Known limits

  • source.default_branch records the branch the source was read at. Read at --tracking-branch dev because its remote's main has no local copy, the plan records default_branch: dev, and plan-tracking-branch-missing names dev as "the source's default branch".
  • A plan read at --tracking-branch goes stale once the default branch becomes local. After git branch main origin/main, check reads the source at main again and finds plan-stale where main's commit is not the plan's: plan is run again.
  • The remediation's commands are quoted for a POSIX shell (shlex.quote), as the tool's other printed commands are.
  • git branch <b> origin/<b> can bring back a branch the remote has since deleted, where the clone was fetched without --prune; the remediation says to run git remote set-head origin --auto first where origin may have changed its default branch, and execute asks the remote itself before any leg (adopt-project.py: the tracking branch defaults to the source's default branch, topics are set before the pull request, and NEXT names scripts/bootstrap.py when the shape's Makefile went aside (#479, #480, #481) #493's review, F1).

Closes #797

🤖 Generated with Claude Code

Summary by Sourcery

Make source branch selection exact and safe, with reliable fallback behavior and actionable remediation when the default branch is unavailable locally.

Bug Fixes:

  • Ensure source adoption reads only the intended local branch, preventing missing-branch failures and accidental reads from tags or unrelated refs.
  • Handle remote default branches that are not locally available by supporting the recorded tracking branch and providing actionable remediation instead of generic Git errors.
  • Preserve complete branch names, including whitespace, non-ASCII, and names beginning with origin/, when selecting and validating the source branch.

Enhancements:

  • Propagate the branch used during planning to check and execute so plans remain readable when the source's default branch is unavailable.
  • Add clear refusal messages and recovery guidance for detached, unborn, renamed-remote, and otherwise non-local default branches.

Tests:

  • Add coverage for full branch-name handling, ref shadowing, missing local default branches, remote default renames, detached and unborn HEADs, unusual branch names, and end-to-end adoption.

Brett Heap and others added 3 commits October 17, 2026 11:04
…ject.py: `_default_branch` reads `origin/HEAD` and HEAD by full ref and whole, and refuses a remote default branch with no local copy by name, so a `git clone -b` source is no longer `git-failed` with no remedy (#797)

Work in progress at the coordinator's usage checkpoint. The four test files
this touches passed on these bytes (93 passed, git-filter-repo on PATH);
the grep's full set in both modes, the revert table and the PR texts are
still to do.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ull name and whole, and reads the source only at a branch of its own, so a `git clone -b` source is no longer `git-failed` with no remedy, and a HEAD on `origin/x`, on `main<U+00A0>` or beside a tag `main` no longer adopts another branch's commit (#797)

`_default_branch` read `git symbolic-ref --short` of `origin/HEAD`, then
of HEAD, stripped the decoded name and took `origin/` off either, and
`Source._at` ran `git rev-parse <name>`. So a `git clone -b dev` source
(`origin/HEAD` naming a `main` it has no copy of) ended `plan`, `check`
and `execute` `git-failed`, with no remedy and whatever `--tracking-branch`
said; a HEAD on `main<U+00A0>` or on a branch `origin/x` read the branch
beside it; and a tag `main` made HEAD's name `heads/main`, or answered
`git rev-parse main` for `origin/HEAD`'s.

Now each is read by its full name, only git's newline is taken off the
bytes, and the commit is `refs/heads/<branch>`'s. A default branch the
source has no branch of is read at `--tracking-branch` (for `check` and
`execute`, at the plan's `source.default_branch`) where that is one of its
own; otherwise `origin/HEAD`'s is refused as
`source-default-branch-not-local`, with both ways through, and never read
at another. The `unsafe-value` refusal of a default branch such as `café`
says the way past it.

This commit names the literals the new tests repeat; the change itself is
the earlier WIP commit, with main 24a977c merged in.

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

sourcery-ai Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

The PR makes source branch selection exact and byte-preserving, restricts commit reads to verified local branch refs, and replaces ambiguous Git failures with safe fallback behavior or actionable refusals. It also carries the branch selected during planning into check and execute, with extensive regression coverage for clone-specific defaults, stale remote HEADs, detached/unborn HEADs, ref shadowing, and unusual branch names.

Sequence diagram for safe source branch selection

sequenceDiagram
    participant Plan
    participant Source
    participant Git

    Plan->>Source: Source.open(..., instead=tracking_branch)
    Source->>Git: symbolic-ref -q refs/remotes/origin/HEAD
    alt origin/HEAD names a local branch
        Source->>Git: for-each-ref refs/heads/
        Source->>Git: show-ref --verify --hash refs/heads/branch
    else default branch is not local
        alt tracking_branch is a local branch
            Source->>Git: show-ref --verify --hash refs/heads/tracking_branch
        else no usable local branch
            Source-->>Plan: Refusal source-default-branch-not-local
        end
    end
    Source-->>Plan: Source with selected branch and commit
Loading

Sequence diagram for preserving the planned source branch

sequenceDiagram
    participant Plan
    participant Source
    participant Git

    Plan->>Source: plan with --tracking-branch
    Source->>Git: resolve default branch and local commit
    Source-->>Plan: record source.default_branch

    Plan->>Source: check or execute with plan
    Source->>Git: resolve using source.default_branch as instead
    Git-->>Source: commit from refs/heads/selected_branch
    Source-->>Plan: validate or execute against planned branch
Loading

Flow diagram for source default branch resolution

flowchart TD
    A[Read origin/HEAD by full symbolic ref] --> B{Valid origin branch reference?}
    B -- Yes --> C[Use its branch name]
    B -- No --> D[Read HEAD by full symbolic ref]
    D --> E{HEAD names a local branch?}
    E -- Yes --> F[Use HEAD branch name]
    E -- No --> G[Guess main]
    C --> H{Branch exists under refs/heads/?}
    F --> H
    G --> H
    H -- Yes --> I[Read refs/heads/branch with show-ref --verify]
    H -- No --> J{instead is a local branch?}
    J -- Yes --> K[Read refs/heads/instead]
    J -- No --> L[Refuse source-default-branch-not-local with remediation]
Loading

File-Level Changes

Change Details Files
Resolve the source branch exactly and read commits only from local branch refs.
  • Read symbolic refs as full byte-preserving names, removing only the expected ref prefixes and Git’s newline.
  • Enumerate local branches for exact membership checks, preserving unusual and non-UTF-8 names.
  • Use git show-ref --verify --hash refs/heads/<branch> to prevent tags or unrelated refs from supplying commits.
  • Propagate the updated branch handling through source opening and branch-list diagnostics.
adopt-project.py
tests/test_adopt_branch_names.py
tests/test_adopt_git_reads.py
Handle unavailable default branches with safe fallback behavior and actionable refusals.
  • Use the requested tracking branch when it is a local source branch, including detached and unborn HEAD cases.
  • Add source-default-branch-not-local refusals with remediation for creating the branch, refreshing origin/HEAD, checking out a branch, or using --tracking-branch.
  • Ignore out-of-scope origin/HEAD targets in favor of HEAD while emitting a diagnostic note.
  • Explain a viable --tracking-branch workaround when the source default branch is not acceptable on command lines.
adopt-project.py
tests/test_adopt_tracking_branch.py
tests/test_adopt_c_locale_bytes.py
Preserve the branch selected during planning for consistent validation and execution.
  • Record the branch actually used to read the source in the plan’s source.default_branch.
  • Pass that recorded branch into check and execute source reopening when the default branch is unavailable.
  • Add coverage for clone selection, deleted branches, renamed remote defaults, detached/orphan HEADs, and end-to-end adoption.
adopt-project.py
tests/test_adopt_tracking_branch.py
Expand regression coverage for ref shadowing, unusual names, and locale-independent behavior.
  • Test branch names containing remote-like prefixes, Unicode whitespace, line separators, and non-UTF-8 bytes.
  • Verify tags and refs with misleading names cannot shadow missing local branches.
  • Validate remediation formatting and behavior across UTF-8 and C locale modes.
tests/test_adopt_branch_names.py
tests/test_adopt_git_reads.py
tests/test_adopt_c_locale_bytes.py

Assessment against linked issues

Issue Objective Addressed Explanation
#797 Read the default branch using full symbolic-ref names, remove only the exact ref prefix and trailing newline bytes, and resolve commits only from the source’s own branch refs so whitespace, similarly named branches, and tags cannot select another commit. ✅
#797 When the remote default branch has no local branch, refuse with actionable remediation rather than silently selecting another commit, while allowing --tracking-branch to select a local branch independently of the default branch. ✅
#797 Explain a workable way past the unsafe-value refusal for a default branch such as café, while preserving the branch name and allowing adoption through a safe tracking branch. ✅

Possibly linked issues


Tips and commands

Interacting with Sourcery

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

Customizing Your Experience

Access your dashboard to:

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

Getting Help

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-17T19:08:24Z) Lane 1's independent review of #830 at 0a369bb: CHANGES NEEDED

All five lines of #797 are fixed and none was already fixed on main. Ordinary sources come out byte-identical to main, and plans cross-check both ways. The gates and CI are green, and my re-runs of the revert table match the writer's.

Four of the findings need changes:

  • an exotic source can still be read at a commit nobody named, where main refused it;
  • the new remediation prints a branch name unspelled;
  • its first way through is wrong for a stale origin/HEAD;
  • SonarCloud reports one new S1192.

Each fix is small.

Setup:

  • Local-only merge of 0a369bba with main bad05d21: clean, giving b2d95a40. Main's 10 newer commits touch adopt-project.py only in _manifest_findings and _validator_judged.
  • Probes: a stub gh (stderr, exit 97) first on PATH, GH_TOKEN=bogus-r797, an empty GH_CONFIG_DIR, GIT_CONFIG_GLOBAL=/dev/null, GIT_ALLOW_PROTOCOL=file, and git 2.43.0.
  • "main" below is bad05d21; "merge" is b2d95a40.

The issue's lines (measured on main and on the merge)

line main merge verdict
L1-71 case 2: clone -b dev, plan exit 2, REFUSED git-failed: git rev-parse main … ambiguous argument 'main' exit 2, REFUSED source-default-branch-not-local: … no branch main of its own (its branches: dev), with both ways through fixed
the same with --tracking-branch dev exit 2, the same git-failed exit 0: default_branch: dev, dev's commit e71951f2 fixed
check and execute after main is deleted each exit 2, git-failed each exit 2, the new refusal fixed
L1-68: HEAD on main<U+00A0> exit 0, default_branch: main, c4e87232 (the other branch) exit 2, unsafe-value … 'main\xa0'; with --tracking-branch main, exit 0 at f2376ba7, its own commit fixed
L1-69: HEAD on origin/x exit 0, default_branch: x, c4e87232 exit 0, default_branch: origin/x, f2376ba7 fixed
L1-71 case 1: café the pattern is the only remedy the remedy says the way past it fixed
tag main read from HEAD heads/main; check gives plan-tracking-branch-missing main at the branch's commit; check says plan ok fixed

Findings, ranked

1. Where the source has no branch of the name, rev-parse refs/heads/<b> falls through to another ref, and the source is read at a commit nobody named. Main refused these; the merge plans them with exit 0.

When _default_branch reaches if origins is None: return branch, the name may not be a local branch. That happens for a detached HEAD's guessed main, or an orphan HEAD's branch. git rev-parse refs/heads/main then looks further, through refs/tags/refs/heads/main, refs/heads/refs/heads/main and refs/remotes/refs/heads/main.

Repro:

git init -b trunk s; <commit>; <commit>; git -C s tag refs/heads/main HEAD~1; git -C s checkout --detach trunk
plan --source s
  • main: exit 2, REFUSED git-failed: git rev-parse main … unknown revision.
  • merge: exit 0, default_branch: main, commit: c4e87232…, which is the tag's commit. trunk is at fa99ac6.

The same happens through refs/remotes/refs/heads/main. An orphan HEAD on new beside a branch literally named refs/heads/new gives exit 0 at that branch's commit.

With a --tracking-branch that origin has (release), nothing flags it:

  • plan exits 0 at the tag's commit;
  • check exits 0 with plan ok.

execute --local-remote-dir refuses, with plan-tracking-branch-missing, only because the source itself is the split's remote there. I could not probe a GitHub remote, because gh is stubbed.

Fix, either of:

  • (a) in _default_branch, never return a name that is not in branches: refuse it by name, with --tracking-branch as the way through. This also retires the PR's first Known limit.
  • (b) read the commit exactly, with git show-ref --verify --hash refs/heads/<b>. On the same source:
    rev-parse refs/heads/main            -> c4e87232… rc=0
    show-ref --verify --hash refs/heads/main -> fatal: 'refs/heads/main' - not a valid ref rc=128
    

2. The new remediation prints origin/HEAD's branch raw through shlex.quote, not _spelled.

The detail spells the name; the remediation line does not. The case below is clone -b trunk of a repository whose default branch is the name in the first column.

default branch detail remediation as printed
ma<U+0085>in `ma\x85in` raw U+0085
ma<U+2028>in `ma\u2028in` raw U+2028
ma<U+009B>2Jin spelled raw 8-bit CSI
caf<E9> (#425) `caf\udce9` git branch 'caf\udce9' 'origin/caf\udce9'

Fix: print the git branch command only where origins.isprintable() and the name has no surrogate. Otherwise name the branch with _spelled and say the step in words.

3. The first way through is wrong in the case the tool's own comment describes: a clone made before GitHub renamed master to main.

The comment is above TRACKING_BRANCH_MISSING. Setup: a clone of a master repository; the remote is renamed to main; the person runs git branch -m master main, git fetch --prune and git branch -u origin/main main, but not git remote set-head.

  • Main says git-failed (git rev-parse master).
  • The merge says the source's origin/HEAD names master, the default branch of its remote origin … and offers git branch master origin/master. Following it gives:
    fatal: not a valid object name: 'origin/master'   (exit 128)
    
  • Without --prune, the same command brings master back at the stale c4e8723, a branch the remote has deleted. Then:
    • plan exits 0 with default_branch: master and tracking_branch: master;
    • check says plan ok;
    • only execute's question to the remote would stop it.

The detail's "the default branch of its remote origin" is also untrue here.

Fix:

  • offer git branch <b> origin/<b> only where refs/remotes/origin/<b> exists;
  • name git remote set-head origin --auto for an origin/HEAD that may be stale;
  • reword the detail (text below).

4. SonarCloud: one new issue, S1192, at adopt-project.py:1973.

The annotation reads "Define a constant instead of duplicating this literal "refs/heads/" 5 times." The PR adds LOCAL_BRANCHES = "refs/heads/", but BRANCH_REFS = "refs/heads/" already exists at line 7369 (_listed_by, _branches_at). Fix: drop LOCAL_BRANCHES and use BRANCH_REFS. Also replace the two literals in _branches_of (lines 3702 and 3710-3711), which makes the count 1.

5. The remediation promises "check and execute then read it where the plan did". That stops being true once the person also takes the first way.

Repro: clone -b dev, then plan --tracking-branch dev (exit 0), then check gives plan ok. Then git branch main origin/main, and:

FINDING plan-stale: the plan was written against e71951f2dbf3 but `main` is now at c4e87232f668.
REFUSED plan-stale: the plan was written against e71951f2dbf3 and `main` is now at c4e87232f668   (execute)

The plan's dev never moved, and main was never the plan's branch. The PR lists this as a Known limit, but the printed text promises otherwise.

Fix, either of:

  • check and execute prefer Plan.read_at wherever it is a branch of the source's own;
  • or reword the promise (text below).

6. Test honesty: NBSP_BRANCH = "main " holds an invisible, literal U+00A0 (bytes 302 240).

with_default_branch skips on any AssertionError from git. With the character turned into an ASCII space (one edit in my revert clone), the four L1-68 cases skip and do not fail:

SKIPPED … no branch here is named b'main ': AssertionError("git switch -q -c main  …
4 skipped, 31 deselected

Fix: write NBSP_BRANCH = "main\u00a0".

7. (Low) An origin/HEAD pointing at another remote's ref is now silently passed over for HEAD's branch.

Setup: origin/HEAD -> refs/remotes/upstream/main, with HEAD on feature.

  • main: read upstream/main (c4e87232) with a NOTE that the tracking branch is no branch.
  • merge: exit 0, default_branch: feature, 6fe6c798, and no NOTE.

The body names this behaviour change. Under "never at another nobody named", a refusal or a NOTE would fit better. This is the coordinator's call.

Tests

  • Revert table: I re-ran 5 rows against the 14 new cases on the merge. All match the writer's table:
    • no change: 14 passed;
    • strip() put back: 4 failed (the four main<U+00A0> cases);
    • rev-parse by short name: 2 failed (the tag cases, HEAD and origin-head);
    • origin/HEAD's branch taken with no local copy: 4 failed (clone refusal, tracking dev, check after deletion, execute at dev);
    • instead ignored: 4 failed (tracking dev, detached, unborn, execute at dev);
    • the café remedy dropped: 1 failed.
  • adopt-project.py _default_branch reads the source's branch name through git_out, which decodes strictly, so a source whose HEAD (or origin/HEAD) names a branch that is not UTF-8 ends plan, check and execute in a UnicodeDecodeError traceback, exit 1, from Source.open, with no plan written and no refusal naming the branch #425 unchanged: main's own tests/test_adopt_git_reads.py and tests/test_adopt_c_locale_bytes.py, run against the merge's adopt-project.py, gave 48 passed with the C locale available and no skips. The C-locale gone case passes because it compares the two modes.
  • Asserts: honest, apart from finding 6. I saw no S9073, S5778 or S3776 problem. Sonar reports 0.0% duplication on new code.
  • My gate on b2d95a40: git-filter-repo on PATH, a stub gh, dirty 0 before and after.
    • the 4 touched test files plus test_cli_reference.py: EXIT=0, 116 passed;
    • test_shape_doctor.py: EXIT=0, 436 passed, 3 skipped, 1 xfailed;
    • test_scaffold_leg_path_collisions.py: EXIT=0, 104 passed.
  • Ordinary sources, byte for byte against main:
    • three sources: a local repo on main, a local repo on master with a feature branch, and a clone on trunk;
    • plan's exit code, stdout, stderr and plan file are all identical;
    • main's plan checked by the merge, and the merge's plan checked by main, both give plan ok, with output identical to main's own.
  • No new network call: the only new git commands are symbolic-ref and for-each-ref.
  • Nothing written before the refusal: I ran plan --out new/sub/p.yaml, check and execute --local-remote-dir against a clone whose main was deleted. The new refusal comes where main's git-failed did:
    • no --out dir and no remote dir are made;
    • the source's refs and status are unchanged;
    • the --work-dir is left empty, as on main.

Windows

  • The test names dev, trunk, x, origin/x, café, main<U+00A0> and the tag main are all legal on NTFS.
  • UNSAFE_NBSP ends in \n, but the adopted helper turns CRLF into LF before comparing, so it holds on Windows.
  • _git_out_exact's os.name == "nt" path is unchanged.
  • Case twins (an origin/HEAD naming Main beside a local main) were not probed. CI is Linux-only.

CI

tests, tests-git-bash, package and SonarCloud (Quality Gate passed) are all green at 0a369bba. The annotations hold the one S1192 above, plus Node 20 and runner-label notices from the workflow itself.

Texts

  • Requirements:
  • If finding 1 is fixed as in (a) or (b), the title and the sentences in items 1-3 below become true, and the first Known limit should be rewritten or dropped. At the head as it is, replace them as follows.
    1. "Only a branch of the source's own is read." → "Only a branch of the source's own is read wherever origin/HEAD or HEAD names one."
    2. "Source._at reads the commit of refs/heads/<branch>, so no tag shadows it." → "Source._at reads the commit of refs/heads/<branch>, so no tag shadows a branch the source has. Where the source has no such branch, git rev-parse looks further, and a ref named refs/tags/refs/heads/<branch> answers in its place."
    3. "else (HEAD's branch with no commit, a guessed main) the name as before, for git rev-parse to refuse in its own words." → "else (HEAD's branch with no commit, a guessed main) the name as before, for git rev-parse refs/heads/<name>, which refuses in its own words unless another ref answers that full name."
    4. Title: "and reads the source only at a branch of its own" → "and reads the source at a branch of its own wherever its remote's default branch names one".
  • Printed remediation (code) and the body's quoted copy:
    5. "check and execute then read it where the plan did." → "check and execute then read it at that branch while the source has no branch <b> of its own."
  • Printed detail (finding 3):
    6. "names <b>, the default branch of its remote origin," → "names <b>, the default branch its remote origin had when it was last asked,"
  • Body, How section: add a bullet saying the remediation offers git branch <b> origin/<b> only where refs/remotes/origin/<b> exists, and git remote set-head origin --auto otherwise.

For the checklist (outside #797)

  • The café way past prints git branch main literally. Where main already exists at another commit, it fails with fatal: a branch named 'main' already exists. L1-68's own source is such a case.
  • A branch whose name is 40 hex digits:
    • main read the object with that id (c4e87232…), not the branch;
    • the merge reads the branch (4eb11972…);
    • fixed, with no test.
  • An origin/HEAD symbolic-ref loop (self-loop or two-step) cannot be read. Both main and the merge fall back to HEAD's branch.
  • A HEAD file written by hand as ref: refs/heads/a..b, or HEAD pointing at refs/remotes/origin/main, guesses main on both, the same as before.
  • A main-written plan whose source's remote default later moves to a branch with no local copy: check reads the plan's recorded branch and passes, where a new plan would refuse. Benign, but asymmetric.

Lane: openreposhape-1 (openRepoShape-1)

…h the source has no branch of, reads its commit by `git show-ref --verify`, spells a name its remediation cannot print, and asks a stale `origin/HEAD` again, so no ref named `refs/heads/<b>` is read for a missing branch (#797)

r797's review of #830 at 0a369bb:
1. A detached HEAD's guessed `main`, or an orphan HEAD's branch, was
   returned though the source has no such branch, and `git rev-parse
   refs/heads/<b>` then found a tag named `refs/heads/main`, a ref
   `refs/remotes/refs/heads/main` or a branch named `refs/heads/new`.
   `_default_branch` now returns only a branch of the source's own and
   refuses any other name as `source-default-branch-not-local`, with
   `--tracking-branch` as the way through, and `Source._at` reads the
   commit with `git show-ref --verify --hash`.
2. The remediation prints `git branch <b> origin/<b>` only where the name
   is printable (a lone surrogate never is); otherwise it spells the name
   and says the step in words.
3. It offers that command only where `refs/remotes/origin/<b>` exists, and
   otherwise `git remote set-head origin --auto`; the detail says "the
   default branch its remote `origin` had when it was last asked".
4. `LOCAL_BRANCHES` is gone for the existing `BRANCH_REFS`, which
   `_branches_of` now uses too (S1192).
5. The remediation says `check` and `execute` read the source at that
   branch "while the source has no branch `<b>` of its own".
6. `NBSP_BRANCH` is written with its escape.
7. An `origin/HEAD` naming a ref outside `refs/remotes/origin/` is named in
   a NOTE as it is passed over.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-17T19:51:16Z) Lane 1's re-verification of #830 at c2bd75c: PR-READY

All seven findings are resolved. This holds once two things happen: CI's tests job goes green, and one false sentence in the body is replaced (text below). tests started at 19:46:17Z and was still pending at 19:50Z. At the previous head it took 1h03m.

I checked on a local merge of c2bd75ce with current main fc867207. The merge is clean, with 0 dirty files. The diff 0a369bba..c2bd75ce touches only adopt-project.py, tests/test_adopt_branch_names.py, tests/test_adopt_git_reads.py and tests/test_adopt_tracking_branch.py. It is one commit, with the Lane trailer.

Findings 1-7

1. A ref other than the branch answering for it: resolved. It is fixed both ways the review proposed: (a) _default_branch returns only a branch the source has, and (b) Source._at reads with show-ref --verify --hash.

source main merge
detached HEAD, a tag refs/heads/main exit 2, git-failed: git rev-parse main exit 2, source-default-branch-not-local: the source names no branch of its own to read: … its HEAD is detached, and it has no branch main …
orphan HEAD on new, beside a branch named refs/heads/new exit 2, git-failed exit 2, its HEAD is on new, which has no commit yet, so it is no branch
  • With --tracking-branch release, which only origin has, the merge is still refused. Before the fix it read the tag and gave plan ok.
  • --tracking-branch trunk is the way through: exit 0, read at trunk.

2. Raw names in the remediation: resolved. For U+0085, U+2028, caf<E9> and U+009B, the output holds no raw C1 or U+2028, and splitlines() gives 2 lines. The step is said in words, for example a branch named `caf\udce9` made from `origin`'s branch of that name. café, which is printable, still gets git branch 'café' 'origin/café'.

3. Stale origin/HEAD: resolved.

  • After the rename and a prune: the remediation says its remote `origin` has no branch `master` either … `git remote set-head origin --auto` . Running that moves origin/HEAD to refs/remotes/origin/main, and plan then exits 0 at main.
  • Without a prune: git branch master origin/master is still offered, now with "where origin may have changed its default branch since, run git remote set-head origin --auto first". The Known limits record this.

4. S1192: resolved. SonarCloud at c2bd75ce passes its gate, with 0 new issues and 0.0% duplication on new code. "refs/heads/" now appears only as BRANCH_REFS.

5. The promise: resolved. It now reads "check and execute then read it at that branch while the source has no branch main of its own." After git branch main origin/main, check still gives plan-stale … main is now at c4e87232f668, which the new wording and the Known limit both describe.

6. The U+00A0 literal: resolved. The line is NBSP_BRANCH = "main\u00a0", and the file now holds 0 raw C2 A0 bytes.

7. origin/HEAD naming another remote's ref: resolved, with a NOTE as the coordinator decided.

  • plan exits 0 at feature, with NOTE the source's `origin/HEAD` names `refs/remotes/upstream/main`, which is no branch of its remote `origin`, so it is passed over ….
  • check prints the NOTE exactly once.

The ", nor one named nope" clause still appears where --tracking-branch names no branch of the source either.

Other checks

  • Ordinary sources, byte for byte against fc867207: three sources, a repo on main, a repo on master with a feature branch, and a clone on trunk.
    • plan's exit code, stdout, stderr and plan file are all identical.
    • main's plan checked by the merge, and the merge's plan checked by main, both give plan ok, with the same output.
  • Revert rows, two spot-checked: both match the writer's table.
    • (a) and (b) reverted together: 3 failed, the tag, remote-ref and branch cases of test_a_ref_named_like_a_missing_branch_is_never_read_for_it.
    • the git branch command printed for any name: 2 failed, the u2028 and not-utf8 cases of test_the_way_through_spells_a_name_no_command_line_carries.
  • Gate on the merge: git-filter-repo on PATH and a stub gh. The four test files the PR touches plus tests/test_cli_reference.py give EXIT=0, 123 passed (116 last round plus the 7 new cases), dirty 0 before and after.
  • CI at c2bd75ce: package, tests-git-bash and SonarCloud pass; tests is pending.
  • Texts:
    • title: 322 characters;
    • body: 20,166 characters;
    • the Lane line, the Claim line, Closes #797 and the robot line are present;
    • no email address, /tmp path or raw U+00A0.

Text replacement (Why table)

This row's "main" cell is false. On main, plan prints NOTE the tracking branch is `upstream/main`, which is no branch of the source (… the source's default branch is `upstream/main`).

  • Replace: exit 0, \default_branch: upstream/main`, that ref's commit, no word of it`
  • With: exit 0, \default_branch: upstream/main`, that ref's commit, and only a NOTE that `upstream/main` is no branch of the source`

I checked the rest of What, Why, How, Tests and Known limits against my probes, and they hold at this head. The U+2028 row is true too: on main it is git-failed with no remedy line.

For the checklist

  • Finding 7's NOTE says "the branch its HEAD is on is read instead" even when HEAD is detached. The same run then refuses with its HEAD is detached …, so the NOTE claims a read that does not happen. Better wording: "…so it is passed over for the branch its HEAD is on, if any".
  • The writer's own carried-over items still stand:
    • the café way past prints git branch main, which fails where a main already exists;
    • a 40-hex branch name is now read as the branch, with no test;
    • an origin/HEAD symbolic-ref loop falls back to HEAD's branch;
    • a plan written on main can pass check where a new plan would refuse;
    • the docstring of test_rev_parse_hands_git_the_name_s_bytes_and_refuses_it_by_name still says rev-parse.

The Why-table replacement above is applied (the PR body is edited; no push).

Lane: openreposhape-1 (openRepoShape-1)

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap marked this pull request as ready for review October 18, 2026 01:14
@brettheap
brettheap merged commit 51414d2 into main Oct 18, 2026
5 checks passed
@brettheap
brettheap deleted the fix/797-adopt-default-branch-refs branch October 18, 2026 01:15

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="adopt-project.py" line_range="1896-1897" />
<code_context>
+        # (#797): `git rev-parse refs/heads/<b>` looks further, and a tag
+        # named `refs/heads/<b>` answers it. As the bytes git keeps the name,
+        # under any locale (#653).
+        commit = _git_out_exact(["show-ref", "--verify", "--hash",
+                                 f"{BRANCH_REFS}{branch}"], path).lower()
         return cls(path, repository, branch, commit, work_root, cloned)

</code_context>
<issue_to_address>
**Symbolic branch refs can read another ref**

When `refs/heads/<branch>` is a symbolic ref pointing to a tag or another ref, rather than a direct branch ref, `_branch_names` accepts a `refs/heads/<branch>` symbolic ref as a local branch, and `git show-ref --verify --hash` follows that symbolic ref. A `refs/heads/main` symbolic ref pointing to `refs/tags/main` therefore lets the tag’s commit be recorded as the branch’s commit, contrary to the claim that only the branch’s own commit is read.

Reject symbolic refs under `refs/heads/` when determining available local branches, or obtain and read only a direct ref object ID without following symbolic refs.

Also at `adopt-project.py:2188-2191`.
</issue_to_address>

### Comment 2
<location path="adopt-project.py" line_range="2089" />
<code_context>
+                                              BRANCH_REFS)
+    branch = origins or head or "main"
+    branches = _branch_names(path, BRANCH_REFS)
+    if branch in branches:
+        return branch
+    if instead in branches:
</code_context>
<issue_to_address>
**Deleted branch causes generic failure**

When another process deletes the selected local branch after `_branch_names` lists it but before `show-ref` reads it, `_default_branch` returns the deleted branch, then `Source._at` runs `show-ref --verify` and `_git_out_exact` raises `git-failed`. The command exits with a generic error instead of reselecting an available fallback or returning the missing-branch refusal.

Handle a selected branch disappearing before verification by rechecking the available fallback or returning the named missing-branch refusal.

Also at `adopt-project.py:1896-1897`.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 2 findings to address first, and if the branch-selection logic chooses the wrong source branch, the plan can record the wrong commit and execute can create and push split branches based on that commit. Reverting the code would not remove those artifacts, but the plan and pushed branches can be corrected or deleted and the adoption rerun.

Blocking findings: adopt-project.py:1897, adopt-project.py:2089


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread adopt-project.py
Comment on lines +1896 to +1897
commit = _git_out_exact(["show-ref", "--verify", "--hash",
f"{BRANCH_REFS}{branch}"], path).lower()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Symbolic branch refs can read another ref

When refs/heads/<branch> is a symbolic ref pointing to a tag or another ref, rather than a direct branch ref, _branch_names accepts a refs/heads/<branch> symbolic ref as a local branch, and git show-ref --verify --hash follows that symbolic ref. A refs/heads/main symbolic ref pointing to refs/tags/main therefore lets the tag’s commit be recorded as the branch’s commit, contrary to the claim that only the branch’s own commit is read.

Reject symbolic refs under refs/heads/ when determining available local branches, or obtain and read only a direct ref object ID without following symbolic refs.

Also at adopt-project.py:2188-2191.

Prompt for AI agents
In `adopt-project.py` at lines 1896-1897:

**Symbolic branch refs can read another ref**

When `refs/heads/<branch>` is a symbolic ref pointing to a tag or another ref, rather than a direct branch ref, `_branch_names` accepts a `refs/heads/<branch>` symbolic ref as a local branch, and `git show-ref --verify --hash` follows that symbolic ref. A `refs/heads/main` symbolic ref pointing to `refs/tags/main` therefore lets the tag’s commit be recorded as the branch’s commit, contrary to the claim that only the branch’s own commit is read.

Reject symbolic refs under `refs/heads/` when determining available local branches, or obtain and read only a direct ref object ID without following symbolic refs.

Also at `adopt-project.py:2188-2191`.

Comment thread adopt-project.py
BRANCH_REFS)
branch = origins or head or "main"
branches = _branch_names(path, BRANCH_REFS)
if branch in branches:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Deleted branch causes generic failure

When another process deletes the selected local branch after _branch_names lists it but before show-ref reads it, _default_branch returns the deleted branch, then Source._at runs show-ref --verify and _git_out_exact raises git-failed. The command exits with a generic error instead of reselecting an available fallback or returning the missing-branch refusal.

Handle a selected branch disappearing before verification by rechecking the available fallback or returning the named missing-branch refusal.

Also at adopt-project.py:1896-1897.

Prompt for AI agents
In `adopt-project.py` at line 2089:

**Deleted branch causes generic failure**

When another process deletes the selected local branch after `_branch_names` lists it but before `show-ref` reads it, `_default_branch` returns the deleted branch, then `Source._at` runs `show-ref --verify` and `_git_out_exact` raises `git-failed`. The command exits with a generic error instead of reselecting an available fallback or returning the missing-branch refusal.

Handle a selected branch disappearing before verification by rechecking the available fallback or returning the named missing-branch refusal.

Also at `adopt-project.py:1896-1897`.

brettheap pushed a commit that referenced this pull request Oct 18, 2026
…trap_after_bump.py` imports both `appended_to_command_scope` and `needs_a_byte_name` (#715)

Main moved from `0dee22b3` to `ca47b71d` while round 3 was paused (#846, #779, #772, #738, #842, #814, #790, #820, #774, #830 and #841). `git merge-tree` of `8b4dbcf6` with it stopped in one path:

- `tests/test_bootstrap_after_bump.py`, its imports. Main's `aec3b38f` (tests append what they tell git to the command scope) added `appended_to_command_scope` to the `from conftest import (...)` list. This branch added `from test_bootstrap_spelling import needs_a_byte_name` below it (#715, round 1). Both are kept.

`scaffold-project.py`, `scripts/bump-leg.py` and `scripts/shape_materialize.py` merged without a conflict.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 18, 2026
Main at 3fb30a8, 37 commits past
24a977c, with #830 (#797, 51414d2) and #833 (#799, 3fb30a8).
adopt-project.py conflicted in eight hunks; docs/cli.md merged by
itself. Each keeps both sides:
- `Source.open`, `Source.of` and `Source.in_place` take #797's
  `instead` and hand it on to `clone`, `in_place` and `_at`; `in_place`
  still takes the top level `_top_level` resolved.
- `_source_named` (#796) and #799's `OUT_UNWRITABLE` block and
  `_write_the_plan` stand side by side.
- `cmd_plan` asks `--out` first (#799: the flags and the file system
  alone), then `_source_spec` (#796), both before `_work_root`, and
  opens the source with `Source.of(named, ..., instead=args.tracking_branch)`.
- `Plan.open_source` keeps `override is not None` and the empty
  `local_path` refusal (#796) and passes `self.read_at` (#797), and
  `_recorded_source` holds a path the person gave to `_top_level` and
  passes `self.read_at` at both `Source.in_place` calls.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 18, 2026
… names no repository's top level is refused by name before git reads anything else in it, so a file, a directory inside a repository, an unknown `~user`, an empty value, `owner/.git` and a lost blob no longer end in a traceback, plan a subtree or reach `gh` (#796) (#831)

`adopt-project.py` took a `--source`, or a plan's `source.local_path`, that names no repository's top level as if it did (#796). Measured on main `24a977c0`:

- a file (L1-64) ended in `NotADirectoryError`, exit 1, after `plan` had made its work directory;
- a directory inside a repository, or `.` run in one (L1-88), planned only that subtree under the repository's name; a directory inside a git directory (`<repo>/.git/objects`, `<bare>.git/refs`) planned the whole repository and recorded the inner path, and `check` and `execute` then failed on it;
- `~nouser/x` (L1-95) ended in `RuntimeError`; `--source ''` (L1-96) planned the directory `plan` ran in, and a plan recording `local_path: ""` had `check` clone the plan's repository from GitHub;
- `owner/.git` in any case (L1-100) reached `gh repo clone`; `www.github.com/o/r` (L1-101) was refused; and a source that lost a blob (L1-77) ended in `ValueError` over git's `BAD` size.

Now:

- **`_top_level`**, new: a local source must be a directory; inside a git directory it must be that git directory itself; and `git rev-parse --show-prefix` must print nothing. Otherwise it is refused under a new code, `source-not-top-level`, which names what was given (a file, `src/` inside the repository at its top level, or the git directory it is inside), with the way out: pass the top-level directory, which `git rev-parse --show-toplevel` prints, or its `org/repo`. A git directory itself (`<repo>/.git`, a bare repository, `.git/modules/<m>`, `.git/worktrees/<w>`) is read whole, as on main.
- **`_source_spec`** refuses an empty value, expands `~` with `os.path.expanduser` (so an unknown `~user` is `source-unresolvable`, not a traceback), and hands every path that exists to `_top_level`. `plan` reads `--source` before it makes its work directory; only the `--pin-source` refusals and `--out` (#833) come first. In `check` and `execute` the work directory is still asked first, as #650 ordered.
- **A recorded `source.local_path`** the person gave is held to `_top_level` too, named by its key, and an empty one is `plan-source-gone`; nothing is cloned for it. A clone `plan` made keeps #749's handling.
- **`_github_name`** refuses the repository `.git` in any case, with no `gh` call, and drops a trailing `.git` in any case; `www.github.com` is read as `github.com`. `scripts/repo_shape.py` is unchanged.
- **`Source.tree`** refuses a size that is not `-` or digits as `git-failed`, naming the path, the blob and `git fsck`.
- **The `source` report line** names the directory read, with the repository its `origin` names beside it; a clone `plan` made is still named by its repository.
- **The help** of `plan`'s, `check`'s and `execute`'s `--source` names the new code, and docs/cli.md is regenerated.

Review: lane 1's independent adversarial review found defects at `adbc5e37`. The fix round fixed them, and the re-verification found `2d51f181` PR-READY. Main was merged twice, the second time (`db202f53`) over #830's and #833's changes to `adopt-project.py`, and the re-verification found that head PR-READY too.

It lands by fast refresh (Brett Heap, 2026-10-15T22:48:35Z). Its reviewed head is green on Linux and SonarCloud. The branch merged main three times and was never rebased; the last merge is update-branch's, clean, giving 7f4d015, which is green on SonarCloud and Git Bash. The full suite passed on its tree in lane 1's gate: 240 test files, 14,677 passed and 69 skipped.

Closes #796

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 18, 2026
One conflict, in `Plan.open_source`'s `--source` branch. Main (#830 for
#797, #831 for #796) passes `self.read_at` to `Source.open`, tests
`override is not None`, and adds the docstring paragraph on an empty
override; the branch keeps the clone made at the plan's own
`source.local_path` (`_keep_the_clone_at_the_record`). Both are kept: main's
docstring, condition and argument, then the branch's call, then `return
source`.

Auto-merged as both sides wrote it: main's `instead` reaches `Source._at`
through the branch's rewritten `Source.clone`; `cmd_plan` asks `--out`
(#833) and `_source_spec` (#831) before `_work_root`'s shared-chain check;
both sides' identical `import errno` is one line.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 18, 2026
…he run's own `mkdir` and removed only while it is still that directory, and a `--work-dir` another user can rename in is refused first, so a swapped `source-readonly` no longer turns the removal onto another directory (#803) (#843)

`plan` and `check` make a clone in the work directory and, since #749, remove it when no plan records it. That removal went by path, so it could be turned onto another directory (#803). Measured on main `24a977c0`:

- **L1-99 (b).** With `source-readonly` swapped for a symlink to a victim, or for the victim itself, at the start of the removal, `check` exited 0 and removed the victim's files. A skipped removal (L1-99 (a)) printed nothing.
- **L1-98.** Two runs in one `--work-dir`: run A removed the clone run B had just planned from.
- **L1-97.** A clone `check --source` made at the plan's own `source.local_path` was removed.
- **L1-102.** On Windows spellings, the clone was kept in silence.
- **L1-65.** A file at `source-readonly` ended `plan`, `check` and `execute` in `FileExistsError`.
- **A work directory another user could rename** (mode 0777, under a 0777 directory, or a `source-readonly` another user owns) was used as any other, and a swap from the directory above turned the removal onto another tree.

Now:

- **A work directory that another user can rename in, or rename from above, is refused before anything is made in it,** under a new code, `adopt-work-dir-shared`. `_work_root` asks each directory above the real `--work-dir`, before and after it makes any, then the work directory itself: a directory owned by a user other than this one or root, or that every user can write in without the sticky bit, is refused by name. This is OpenSSH's StrictModes rule, with the sticky bit counted (so `/tmp` stays open) and group write not asked. Where the shared directory is this user's, the way out also names `chmod +t <A>`. A symlink loop or a file above the work directory keeps #650's refusal. Not asked on Windows, nor of the default work directory.
- **`source-readonly/` is asked before the NOTE and before `gh`:** a symlink or a file there is `adopt-work-dir-occupied`, a shared one `adopt-work-dir-shared`, and one this user cannot make a directory in `adopt-work-dir-unwritable`.
- **The clone's directory is claimed before `gh` runs,** with an exclusive `os.mkdir`; any target already there, an empty one included, is refused as `adopt-work-dir-occupied`. Every directory a run makes for its work directory, `source-readonly/` and the clone is made 0755 whatever the umask, which can still narrow it (0700 under `umask 077`).
- **The removal is held to what this run made.** The identity (`st_dev`, `st_ino`) of the clone's directory and of `source-readonly/` is recorded right after the `mkdir`, and the removal goes ahead only while both match, never through a symlink: relative to a descriptor of `source-readonly/` from Python 3.11 on Linux and macOS, and by path after the same checks elsewhere. A skipped removal gets a NOTE; paths are compared with `os.path.normcase`; the plan's own record is kept; and `execute` removes exactly the claim its own `mkdir` made when the clone does not finish.
- **What stays open is said in the body's "The residual window" and known limits:** group write, Windows, root, this user's own processes, drives mounted without ownership, and root over another user's directory.
- The `--work-dir` help of all three commands names the new refusals, and docs/cli.md is rendered again.

Review: lane 1's independent adversarial review found defects at `cf36411e`. Fix round 1 (`bba7e445`) and fix round 2 (`08f7a283`) fixed them, the second after `/workspace/projects` was given the sticky bit on Brett Heap's word. The merge round (`b1a40a64`) merged main over #830's and #831's changes to `Plan.open_source`, and the re-check found that head PR-READY.

It lands by fast refresh (Brett Heap, 2026-10-15T22:48:35Z). Its reviewed head is green on Linux and SonarCloud. The branch merged main twice and was never rebased; the last merge is update-branch's, clean, giving cfe8e35, which is green on SonarCloud and Git Bash. The full suite passed on its tree in lane 1's gate: 247 test files, 15,381 passed and 48 skipped.

Closes #803

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 18, 2026
…ed git call fails the test and an encoding that cannot spell U+202E skips the bidi test, and six template-value tests skip under a C locale where they raised `UnicodeEncodeError` (#853, #854) (#869)

Six test helpers skipped on any failure of `make_source_repo` or of a git
call, so a real failure of git read as a skip (#853). The three branch
helpers, among them `remembered_on` (the sixth site, added by #830), now ask
the system first: whether the file name encoding can spell the name, and,
for a name that is not UTF-8, whether the disk can hold it. A git call that
then fails fails the test. The U+202E test asks whether the encoding can
spell U+202E, and skips where it cannot (#854). So do #845's two U+202E
tests in `tests/test_non_utf8_oserror_sites.py`, which would otherwise skip
before the disk or git is reached.

Beyond the two issues, six tests in `tests/test_adopt_template_values.py`
raised `UnicodeEncodeError` under a C locale (`LC_ALL=C LANG=C PYTHONUTF8=0
PYTHONCOERCECLOCALE=0`), the same kind of failure as #854, in a file this
change already touches. Each now asks `skip_where_the_environment_cannot_carry`
first and skips there:
- `test_plan_refuses_a_flag_no_file_can_hold`, for `--elected-by` with
  U+2028, for `--elected-by` with U+202E, and for `--reference` with U+2066
  and U+2069;
- `test_a_valid_non_ascii_elector_still_adopts`, accented and with a
  zero-width non-joiner;
- `test_a_utf8_source_directory_still_adopts_with_local_remotes`, for the
  directory `caf` with U+00E9.
`windows_fsencode` takes a path, as `os.fsencode` does. Python 3.12 coerces
a plain `LC_ALL=C` to UTF-8, so the skips show only with both variables set
to 0.

Lane 3's independent reviewer RV869 (opus) found it READY (#869
issuecomment-6245399807), and RV869b (opus) found the fix round after it
READY (issuecomment-6245721915). After #845 landed, it was restacked onto
#845's squash by two merges, with `tests/conftest.py` merged by hand to keep
main's newer helpers, and its changed lines equal the reviewed delta (#869
issuecomment-6248744839). It lands on Brett Heap's word "merge lane 3's
v1.1.1 drafts after the v1.1.0 tag" (2026-10-17T13:00:57Z).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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