Skip to content

adopt-project.py: a --source, or a plan's source.local_path, that 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

Merged
brettheap merged 7 commits into
mainfrom
fix/796-adopt-source-spellings
Oct 18, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

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

What

adopt-project.py now refuses, by name and with a way out, every --source (and every recorded source.local_path) that names no repository's top level and no repository on GitHub. Each refusal comes before git reads anything else in the source, before any clone and before any gh call. plan now reads --source before it makes its work directory. Each line of #796, and the three findings of this PR's review (r796):

Line What was given Now
L1-64 a file source-not-top-level (new): "--source '…/Thing/README.md' is not a directory, and a source is the top-level directory of a repository"
L1-88 a directory inside a repository, or . run in one source-not-top-level: "--source '…/Thing/src' is src/ inside the repository at …/Thing, and a source is the top-level directory of a repository: read from there, it would be only what is under src/"
L1-88 (review F1) a directory inside a git directory: <repo>/.git/objects, <bare>.git/refs source-not-top-level: "--source '…/Thing/.git/objects' is inside the git directory …/Thing/.git, and a source is the top-level directory of a repository". A git directory itself (<repo>/.git, a bare repository, .git/modules/<m>, .git/worktrees/<w>) is read whole, as on main.
L1-88 a plan whose source.local_path is any of those source-not-top-level, named by its key: "the plan's source.local_path, …/Thing/src, is src/ inside …"
L1-88 rider the source report line names the directory read, with the repository its origin names beside it: source …/Thing (origin: testorg/Thing) @ …. A clone plan made is still named by its repository.
L1-95 ~nouser/x source-unresolvable: "--source '~nouser/x' is neither a path that exists nor an org/repo name". Recorded, it is a path that is gone: plan-source-gone, or cloned again where the plan records cloned: true.
L1-96 --source '' (or "$SRC" with SRC unset) source-unresolvable: "--source '' names no path: an empty one would be read as the directory this runs in". check and execute refuse it too; they no longer drop it.
L1-96 (review F3) a plan whose source.local_path is "" plan-source-gone: "the plan's source.local_path is empty: it names no path, and an empty path would be read as the directory this runs in". Nothing is cloned.
L1-100 (review F2) owner/.git in any case, https://github.com/owner/.git.git source-unresolvable ("is neither a path that exists nor an org/repo name"; "names no owner/repo on github.com"), and no gh call
L1-101 https://www.github.com/o/r, www.github.com/o/r read as github.com: cloned as o/r
L1-77 a source that lost a blob git-failed: "git ls-tree -r -l -z <commit> in gives no size for README.md, whose blob it cannot read: it prints BAD", with "Remediation: run git fsck in : it names each object the repository lacks. …"

source-not-top-level's way out, for a --source and a recorded path alike: "Remediation: pass --source the repository's top-level directory, which git rev-parse --show-toplevel prints in any directory of it, or its org/repo."

The new code is named in plan --source's help and in check's and execute's --source help, and docs/cli.md is regenerated from them.

Why (repro on main)

Probed on main 24a977c0 with a stub gh first on PATH, GIT_CONFIG_GLOBAL=/dev/null and a 10-file source whose origin is https://github.com/testorg/Thing.git:

  • L1-64. plan --source <repo>/README.md --work-dir W ended in NotADirectoryError: [Errno 20] Not a directory: '…/Thing/README.md', exit 1, and W had been made and stayed. check --source and execute --source end the same way: the traceback runs Source.in_place → _remote_repository → _origin_url → git_out.
  • L1-88. plan --source <repo>/src exited 0: source testorg/Thing @ 2519526bac4e (main), 4 files, 1 commits, with local_path: "…/Thing/src". plan --source . run in <repo>/docs exited 0 with 2 files. check of that plan was refused a git or gh command failed; execute was refused over what the subtree lacked (adopt-empty-leg-unconsented for this tree; the issue measured adopt-remote-unreadable for another). The source line named testorg/Thing whatever directory was read.
  • L1-88, inside a git directory (r796, on main bad05d21). plan --source <repo>/.git/objects exited 0, planning the whole repository and recording local_path: "…/Thing/.git/objects"; check was then refused a git or gh command failed at git ls-remote of it, and execute adopt-remote-unreadable. <bare>.git/refs behaved the same.
  • L1-95. plan --source '~nouser/x' ended in RuntimeError: Could not determine home directory., exit 1, from Path(spec).expanduser(); a plan recording that path did the same at Plan._recorded_source.
  • L1-96. plan --source '' run inside the repository exited 0 and planned it (10 files). check --source '' exited 0, having read the plan's own source. And (r796) a plan edited to local_path: "", cloned: false, repository: testorg/Thing made check exit 0 after gh repo clone testorg/Thing: the plan's repository was cloned from GitHub, which _recorded_source never does for a path the person gave.
  • L1-100. plan --source owner/.git ran gh repo clone owner/.git …/source-readonly/.git and was refused source-unclonable, with a remedy about gh auth status. (r796) testorg/.GIT, testorg/.Git.git and https://github.com/testorg/.GIT reached gh as well.
  • L1-101. https://www.github.com/testorg/Thing was refused as "a repository on www.github.com"; www.github.com/testorg/Thing as "neither a path that exists nor an org/repo name".
  • L1-77. With one blob removed from a loose object store, git ls-tree -r -l prints BAD for its size and exits 0, and plan ended in ValueError: invalid literal for int() with base 10: 'BAD', exit 1.

How

All in adopt-project.py:

  • _top_level(path, named), new. A local source that exists must be a directory (asked of the disk; git never runs in a file). Then git rev-parse --is-inside-git-dir: inside a git directory, the directory must be that git directory itself (--absolute-git-dir, by real path), or it is refused, naming the git directory. Then git rev-parse --show-prefix must print nothing, which it does at the top level of a work tree and in a git directory. Only when the prefix is not empty is --show-toplevel asked, for the refusal to name. A directory git reads no repository from is still refused git-failed, in git's own words, as a later read refused it on main.
  • _source_spec refuses an empty value first, in the manner of pin-source-no-path. It expands ~ with os.path.expanduser, which leaves a ~user this machine has not as typed, and it hands every path that exists to _top_level, returning the resolved top level.
  • Plan._recorded_source uses os.path.expanduser, and holds a path the person gave (cloned not true) to _top_level, naming it by its key. A clone plan made keeps adopt-project.py: --source is read by its spelling, an org/repo in any spelling is cloned by its name, the plan records source.cloned, and a clone no plan records is removed, so a recorded clone that is gone or emptied is refused or cloned again for what it is (#652) #749's handling: _not_a_whole_clone and a fresh clone.
  • Plan.open_source tests override is not None, so an empty --source reaches _source_spec and is refused; and it refuses a local_path of "" as plan-source-gone before its if local_path: test. _gone's way out is now the constant PLAN_SOURCE_GONE_REMEDY, which both use.
  • Source.open is now _source_spec then Source.of, and cmd_plan calls _source_spec before _work_root, so plan makes no work directory for a refused --source.
  • _github_name refuses the repository .git in any case (owner/.git, owner/.GIT, owner/.git.git), and drops a trailing .git in any case, as GitHub reads a repository's name without regard to case: testorg/Thing.GIT is cloned as testorg/Thing. It already refused . and .. (since adopt-project.py: --source <org/repo> reads a directory of that name under the current one, a trailing / clones into source-readonly/ itself, and a recorded clone that is gone or emptied is refused for the wrong reason #652); two control cases now hold that through --source. url_repository, in the pinned scripts/repo_shape.py, is unchanged.
  • SOURCE_URL_RE reads www.github.com/ with no scheme, and _repository_in_url reads the host www.github.com as github.com.
  • Source.tree refuses a size that is neither - nor ASCII digits, as git-failed, naming the path, the blob and git fsck.
  • _source_named, new, writes the source line's name: the clone's repository, or the directory read with (origin: <repo>) beside it.

Order, and one deliberate exception. In plan every --source refusal now comes before the work directory is made, before any clone and before any gh call; for a local path only git rev-parse has run. Only the flags plan asks first come before it: the --pin-source refusals and, since #833 (#799), --out (adopt-out-unwritable), which need no process. In check and execute the --source refusals come before any other git, any clone and any gh call, but AFTER the work directory is asked: #650 put the work directory first on purpose, and tests/test_adopt_work_dir_refusal.py::test_check_refuses_a_source_it_cannot_open_as_execute_does pins that order. So in check and execute a --work-dir that did not exist yet is made before a bad --source is refused, as it is for every other --source refusal there on main. Accepted by the lane-1 coordinator.

Tests

tests/test_adopt_source_clone_record.py and tests/test_adopt_work_dir_refusal.py are extended. Written first: run against main's adopt-project.py (24a977c0), the 47 new cases fail, and the 76 existing cases and the 7 new controls pass (47 failed, 83 passed). No existing test was changed; new cases were added to three of #749's parametrize tables (SPELLINGS: www-url, www-no-scheme, dot-git-upper-case (testorg/Thing.GIT); NAMES_NO_REPOSITORY: dot-git-repository, dot-git-upper-case (testorg/.GIT), dot-git-mixed-case (testorg/.Git.git), url-dot-git-repository, and the two controls dot-dot-git-repository, url-dot-dot-repository; the --help test: source-not-top-level for all three commands and source-unresolvable for plan).

New tests, each for plan, check and execute where it applies: a file, a directory inside the repository and .git/objects, each given as --source and recorded as local_path; <bare>.git/refs; . run in a subdirectory; the controls (the top level and . there; <repo>/.git, a bare repository and a linked worktree's git directory, each read whole); the source line; ~no-such-user-796/Thing given and recorded, and a recorded clone under it cloned again; --source '' run inside the repository; a recorded local_path: ""; a lost blob; and plan given each of a file, a directory inside, '' and an unknown ~user with a --work-dir that does not exist, which is never made. Each asserts the exit and the refusal (whole where its words are fixed; its code, first line or named parts where it holds a path or an object id), no plan written, no leg repository and no gh call; the --work-dir cases assert the code's prefix, no plan written, and that the directory was never made.

Revert table. Each change reverted alone in adopt-project.py at 2d51f181, running both files (130 tests; 153 with tests/test_cli_reference.py, added for the help row):

Change reverted alone Tests that fail
_top_level: a path that is not a directory 6: test_a_source_that_is_no_top_level_is_refused_by_name[file-plan,file-check,file-execute], test_a_recorded_local_path_that_is_no_top_level_is_named_by_its_key[file-check,file-execute], test_plan_refuses_a_source_before_it_makes_the_work_dir[a-file]
_top_level: a directory inside a git directory (--is-inside-git-dir) 6: test_a_source_that_is_no_top_level_is_refused_by_name[git-directory-plan,git-directory-check,git-directory-execute], test_a_recorded_local_path_that_is_no_top_level_is_named_by_its_key[git-directory-check,git-directory-execute], test_a_directory_inside_a_bare_repository_is_refused
_top_level: a --show-prefix that is not empty 7: test_a_source_that_is_no_top_level_is_refused_by_name[directory-plan,directory-check,directory-execute], test_a_recorded_local_path_that_is_no_top_level_is_named_by_its_key[directory-check,directory-execute], test_dot_in_a_directory_inside_the_repository_is_refused, test_plan_refuses_a_source_before_it_makes_the_work_dir[a-directory-inside]
_source_spec: the empty value 4: test_an_empty_source_names_no_path_and_is_refused[plan,check,execute], test_plan_refuses_a_source_before_it_makes_the_work_dir[empty]
Plan.open_source: override is not None (back to if override:) 2: test_an_empty_source_names_no_path_and_is_refused[check,execute]
Plan.open_source: the empty local_path 2: test_an_empty_recorded_local_path_is_refused_and_never_cloned[check,execute]
_source_spec: os.path.expanduser (back to Path.expanduser) 4: test_an_unknown_user_in_the_source_is_unresolvable[plan,check,execute], test_plan_refuses_a_source_before_it_makes_the_work_dir[unknown-user]
Plan._recorded_source: os.path.expanduser 3: test_a_recorded_path_under_an_unknown_user_is_gone[check,execute], test_a_recorded_clone_under_an_unknown_user_is_cloned_again
Plan._recorded_source: _top_level for a path the person gave 6: test_a_recorded_local_path_that_is_no_top_level_is_named_by_its_key[file-check,file-execute,directory-check,directory-execute,git-directory-check,git-directory-execute]
_github_name: the repository .git, removed 4: test_a_source_that_names_no_github_repository_never_reaches_gh[dot-git-repository,dot-git-upper-case,dot-git-mixed-case,url-dot-git-repository]
_github_name: .git compared in its case alone 2: test_a_source_that_names_no_github_repository_never_reaches_gh[dot-git-upper-case,dot-git-mixed-case]
_github_name: a trailing .git dropped in its case alone 1: test_each_spelling_of_a_name_is_cloned_and_recorded_as_owner_repo[dot-git-upper-case]
_repository_in_url: the host www.github.com 2: test_each_spelling_of_a_name_is_cloned_and_recorded_as_owner_repo[www-url,www-no-scheme]
SOURCE_URL_RE: www. with no scheme 1: test_each_spelling_of_a_name_is_cloned_and_recorded_as_owner_repo[www-no-scheme]
Source.tree: the size check 3: test_a_source_that_lost_a_blob_is_refused_naming_it_and_git_fsck[plan,check,execute]
_source_named: the source line (back to the repository alone) 1: test_the_source_line_names_the_directory_read_beside_its_repository
cmd_plan: _source_spec before _work_root (back to after it) 4: test_plan_refuses_a_source_before_it_makes_the_work_dir[a-file,a-directory-inside,empty,unknown-user]
the three --source help texts 9: test_the_source_help_names_its_refusal_whole_on_one_line[plan-source-not-top-level,check-source-not-top-level,execute-source-not-top-level,plan-source-unresolvable], and 5 in tests/test_cli_reference.py (the committed docs/cli.md no longer matches)

The seven controls (the two ./.. cases, the top level and . there, and the three git directories read whole) pass with and without the change.

Both modes, on head db202f537c89f35d377b6285368c553830eaaa2e (dirty 0), one mode after the other, four shards each, every file run on its own: the 101 files grep -l -E 'adopt-project|adopt_project|\bADOPT\b' tests/*.py names, plus tests/test_shape_doctor.py, tests/test_scaffold_leg_path_collisions.py, tests/test_cli_reference.py and tests/test_agents_md_codes.py, 102 files:

Mode Files exit 0 Passed Skipped Failed
git filter-repo on PATH 101 of 102 7285 13 0
git filter-repo hidden (PATH=<stub gh>:/usr/local/bin:/usr/bin:/bin) 101 of 102 6896 402 0

The 102nd is tests/conftest.py, which the grep names because it defines ADOPT: run alone, it holds no test, and pytest exits 5 ("no tests ran"). Each run: /usr/bin/python3 -m pytest -q -p no:cacheprovider --basetemp=<the shard's> -o tmp_path_retention_policy=failed <one file>, a stub gh first on PATH, GH_TOKEN=bogus-w796, an empty GH_CONFIG_DIR, GIT_CONFIG_GLOBAL=/dev/null and GIT_SSH_COMMAND=false.

Merges of main

Windows

CI is Linux only (#597). The new tests write no file with write_text that they read back as bytes, and every path they make is legal on NTFS. os.path.expanduser on Windows turns ~nouser\x into a path beside the user's profile, which is not there, so it is source-unresolvable there too.

Not in this PR

  • L1-65 (a source-readonly that is a file) goes with the clone-removal hardening issue, beside Source.clone; Source.clone, _leave_the_clones and _remove_a_tree are untouched.
  • check's WHAT WILL HAPPEN, against <repository> @ line still names a local source by its repository; the issue names only plan's source line.
  • url_repository (the pinned scripts/repo_shape.py) still drops .git in its case alone and once, so an origin ending Thing.GIT names testorg/Thing.GIT, as on main.

Known limits

  • A --source directory this user cannot enter (chmod 000), or one under a directory they cannot enter, still ends in a PermissionError traceback, exit 1, as on main: from git rev-parse in _top_level here, from _origin_url on main, and from Path.exists in _source_spec for the second on both. Reported for Tracking: adopt-project.py, converting an existing repository in place #457's checklist.

Closes #796

🤖 Generated with Claude Code

Summary by Sourcery

Harden source validation and error reporting so adoption only reads valid repository roots and fails safely before side effects.

New Features:

  • Validate every local source as a repository top-level directory before reading it, cloning, or invoking GitHub tooling.
  • Add explicit handling for empty, unresolved, invalid GitHub, and missing recorded source paths.
  • Recognize www.github.com source spellings and reject repository names that resolve to .git.
  • Detect missing Git blobs and report actionable git fsck remediation.

Bug Fixes:

  • Prevent source files, repository subdirectories, Git-internal subdirectories, and empty sources from causing tracebacks, unintended subtree plans, or accidental fallback to the current directory.
  • Ensure recorded invalid or empty local paths are refused instead of being silently cloned or ignored.
  • Prevent invalid GitHub source names from reaching gh.
  • Ensure plan validates sources before creating its work directory.

Enhancements:

  • Improve plan source reporting by naming the directory read and its origin repository.
  • Centralize remediation guidance for missing recorded sources and top-level validation.

Documentation:

  • Regenerate CLI documentation and help text to describe source validation and refusal cases.

Tests:

  • Add coverage for invalid local sources, Git directories, empty and unresolved paths, invalid GitHub names, missing blobs, source reporting, and validation ordering.

Brett Heap and others added 4 commits October 17, 2026 11:04
…ject.py: a `--source` or a recorded `local_path` that names no repository's top level is refused by name (#796)

Work in progress for #796 (L1-64, L1-88 and its rider, L1-95, L1-96,
L1-100, L1-101, L1-77):
- `source-not-top-level`, a new code, for a file or a directory inside a
  repository (`_top_level`, `git rev-parse --show-prefix`), given as
  `--source` or recorded as `source.local_path`;
- an empty `--source` refused `source-unresolvable`, and `check` and
  `execute` no longer drop one (`override is not None`);
- `os.path.expanduser`, so an unknown `~user` is a path that is not there;
- `_github_name` refuses the repository `.git`; `www.github.com` is
  GitHub's host;
- `Source.tree` refuses a size git prints as `BAD`, naming the path and
  `git fsck`;
- `plan` reads `--source` before it makes the work directory;
- the `source` line names the directory read, with its `origin`'s
  repository beside it;
- `--help` and docs/cli.md name the new code.

The two test files passed (114 passed) one edit before these bytes; the
last edits (the `_source_named` helper, a test helper, a comment) are
untested, and neither mode has been run in full yet.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main at 24a977c, eleven commits past
bf1f099; git merged it with no conflict.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ry of a `--source` name stay refused, as controls beside `.git`, so a later change to `_github_name` that read them as a repository no longer passes unseen (#796)

`_github_name` has refused `.` and `..` since #652, whatever `.git`
follows them (`testorg/..git`, `https://github.com/testorg/..`); no test
held that through `--source`. Two control cases join #796's `.git`
cases in `NAMES_NO_REPOSITORY`, and the phrase a url names is a
constant.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the `git filter-repo` check, as `planned_from_github` does, so `execute` of a source that lost a blob reaches `Source.tree` with `git filter-repo` off PATH too, where it was refused `filter-repo-missing` first (#796)

`execute` asks for `git filter-repo` after it opens the source and
before it reads the source's tree. With it hidden, as in the second of
the two modes, `test_a_source_that_lost_a_blob_is_refused_naming_it_and_git_fsck[execute]`
met `filter-repo-missing` and never the lost blob. The other tests that
use `planned_here` are refused before that check either way.

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 centralizes source validation so local and recorded sources must identify a repository top level, rejects malformed or unresolvable inputs before cloning or downstream GitHub operations, hardens Git tree error handling, improves source reporting and CLI documentation, and adds comprehensive regression coverage including refusal ordering and side-effect checks.

Sequence diagram for refusal ordering in plan source handling

sequenceDiagram
    participant User
    participant Plan
    participant SourceSpec as _source_spec
    participant Git as git rev-parse
    participant WorkDir as _work_root
    participant GH as gh
    User->>Plan: plan --source spec
    Plan->>SourceSpec: _source_spec(spec)
    alt Existing local path
        SourceSpec->>Git: rev-parse --show-prefix
        alt Path is not repository top level
            Git-->>SourceSpec: non-empty prefix
            SourceSpec-->>Plan: Refusal: source-not-top-level
        else Top-level path
            SourceSpec-->>Plan: resolved path
        end
    else Empty or unresolvable spec
        SourceSpec-->>Plan: Refusal: source-unresolvable
    else GitHub repository name
        SourceSpec-->>Plan: repository name
    end
    alt Refused
        Plan-->>User: no work directory, clone, or gh call
    else Accepted
        Plan->>WorkDir: _work_root(args)
        opt Repository name
            Plan->>GH: clone repository
        end
    end
Loading

Sequence diagram for recorded source validation

sequenceDiagram
    participant CheckExecute as check or execute
    participant Plan
    participant Source as Source.open
    participant Recorded as Plan._recorded_source
    participant Git as git rev-parse
    CheckExecute->>Plan: open_source(override)
    alt override is not None
        Plan->>Source: open(override)
        Source->>Source: _source_spec(override)
    else Use recorded local_path
        Plan->>Recorded: _recorded_source(local_path)
        Recorded->>Recorded: os.path.expanduser(local_path)
        alt Existing non-clone path
            Recorded->>Git: _top_level(local_path)
            Git-->>Recorded: top-level or refusal
        else Gone recorded path
            Recorded-->>Plan: plan-source-gone
        end
    end
    alt Invalid source
        Source-->>CheckExecute: source-not-top-level or source-unresolvable
    else Valid source
        Source-->>CheckExecute: opened Source
    end
Loading

Flow diagram for source validation and opening

flowchart TD
    A["--source or recorded source"] --> B{Empty value?}
    B -- Yes --> R1["Refuse: source-unresolvable"]
    B -- No --> C{Existing local path?}
    C -- No --> D{Valid org/repo or GitHub URL?}
    D -- No --> R2["Refuse: source-unresolvable"]
    D -- Yes --> G["Clone repository"]
    C -- Yes --> E{Directory at repository top level?}
    E -- No --> R3["Refuse: source-not-top-level"]
    E -- Yes --> L["Read source in place"]
    G --> S["Open Source"]
    L --> S
Loading

File-Level Changes

Change Details Files
Validate local sources before repository inspection and normalize source resolution across commands.
  • Reject empty values, files, and repository subdirectories with actionable refusals.
  • Resolve ~user paths without raising expansion tracebacks and preserve empty overrides for check/execute.
  • Validate recorded source.local_path values using the same top-level rules.
  • Resolve plan --source before creating the work directory; retain the documented work-directory ordering for check and execute.
  • Add help text and regenerate CLI documentation.
adopt-project.py
docs/cli.md
Harden GitHub source-name parsing and improve source reporting.
  • Treat www.github.com spellings as GitHub URLs.
  • Reject .git as a repository name before invoking gh.
  • Report the actual local directory read alongside its repository origin, while naming clones by repository.
adopt-project.py
Convert missing Git object failures into explicit actionable refusals.
  • Detect non-numeric blob sizes emitted by git ls-tree and report the affected blob and git fsck remediation instead of raising ValueError.
adopt-project.py
Add regression coverage for source validation, ordering, parsing, reporting, and corrupted repositories.
  • Cover file and subtree sources, recorded paths, empty and unknown-user values, GitHub spellings, .git names, lost blobs, source output, and help text.
  • Verify refused plans write neither plans nor work directories and make no clone or gh calls.
  • Exercise existing behavior for top-level and clone sources as controls.
tests/test_adopt_source_clone_record.py
tests/test_adopt_work_dir_refusal.py

Assessment against linked issues

Issue Objective Addressed Explanation
#796 Refuse invalid local sources by name before they can cause a traceback or plan a subtree: reject files, directories below a repository's top level, empty values, and unknown ~user paths, including recorded local paths; preserve the source report's actual local path. ✅
#796 Reject GitHub source names that cannot be cloned, recognize www.github.com as github.com, and ensure invalid names do not reach gh. ✅
#796 Refuse a source with a missing or unreadable blob with a named git-failed error and git fsck remedy instead of a traceback. ✅

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:12:13Z) Lane 1's independent review of #831 at adbc5e3: CHANGES NEEDED

I made a local-only merge of head adbc5e377cd2 with main bad05d21e27e, giving 3a0386b8; the merge is clean. Main is 11 commits ahead of the head, and one of those commits, 64ec47c3, touches adopt-project.py, so the branch needs refreshing before it lands. Every probe ran with a stub gh first on PATH that logs its arguments, plus GH_TOKEN=bogus-r796, an empty GH_CONFIG_DIR, GIT_CONFIG_GLOBAL=/dev/null and GIT_ALLOW_PROTOCOL=file. The test source was a 10-file repository whose origin is https://github.com/testorg/Thing.git.

The issue's lines (main bad05d21 against the merge)

Line main merge Verdict
L1-64, a file (also a FIFO and /dev/null) plan, check and execute exit 1 with NotADirectoryError, and plan had already made --work-dir exit 2 source-not-top-level; nothing is made fixed
L1-88, a directory inside a repository src, src/deep/one/two, a symlink to src, <worktree>/src and <submodule>/src each exit 0; . run in src exits 0 with "3 files" exit 2 source-not-top-level, naming the right top level, including the worktree's and the submodule's fixed, except inside .git (F1)
L1-88 rider, the source line source testorg/Thing @ source …/Thing (origin: testorg/Thing) @ fixed
L1-95, ~nouser/x RuntimeError, exit 1, given or recorded source-unresolvable. Recorded, it is plan-source-gone, or a NOTE and a fresh clone where the plan records cloned: true fixed
L1-96, '' plan --source '' in src exits 0 and plans 3 files; check '' exits 0; execute '' drops the value exit 2 source-unresolvable from all three fixed (a recorded "" is F3)
L1-100, owner/.git gh [repo] [clone] [testorg/.git] […/source-readonly/.git] no gh call; source-unresolvable fixed, but not .GIT (F2)
L1-101, www.github.com refused all 8 www spellings give gh <testorg/Thing> and record repository: testorg/Thing fixed
L1-77, a lost blob ValueError, exit 1 git-failed naming the path, the blob and git fsck fixed

Findings, most important first

F1. A directory inside a git directory still passes _top_level. plan exits 0, and check and execute then fail exactly as L1-88 did. Inside a git directory, --show-prefix prints nothing, so these are taken as top levels: <repo>/.git/objects, <repo>/.git/refs and <bare>.git/refs.

plan --source …/Thing/.git/objects …   main: EXIT=0   merge: EXIT=0 (the two plans are byte-identical)
  source     …/Thing/.git/objects (origin: testorg/Thing) @ 34d3d6e2b5cd (main), 10 files, 1 commits
  local_path: "…/Thing/.git/objects"
check --plan P        (main and merge alike) EXIT=2
  REFUSED a git or gh command failed. THE EXACT COMMAND WAS:
    git -c protocol.file.allow=always ls-remote --heads -- …/Thing/.git/objects
  fatal: '…/Thing/.git/objects' does not appear to be a git repository
execute --plan P …    (main and merge alike) EXIT=2
  REFUSED adopt-remote-unreadable: `git ls-remote` could not read `…/Thing/.git/objects`, the repository the split is pushed to, …

<bare>.git/refs behaves the same way.

The PR's Known limits says such a directory "is read as on main, as the repository". That is true of plan, but the plan it writes cannot be checked or executed.

A fix I tried in a scratch clone, placed in _top_level before the --show-prefix call:

    if git_out(["rev-parse", "--is-inside-git-dir"], cwd=resolved) == "true":
        git_dir = os.fsdecode(git_out(["rev-parse", "--absolute-git-dir"],
                                      cwd=resolved, binary=True).rstrip(b"\n"))
        if os.path.realpath(git_dir) != str(resolved):
            raise Refusal(
                SOURCE_NOT_TOP_LEVEL,
                f"{named} is inside the git directory {_spelled(git_dir)}, "
                f"and {TOP_LEVEL_ONLY}", TOP_LEVEL_REMEDY)

With it:

  • .git/refs, .git/objects and bare.git/refs exit 2 source-not-top-level.
  • <repo>/.git, bare.git, .git/modules/sub, .git/worktrees/wt, the top level, a linked worktree and a submodule checkout still exit 0.
  • test_adopt_source_clone_record.py, test_adopt_work_dir_refusal.py and test_adopt_plan_escapes.py give 175 passed.

Add a parametrized case (.git/objects given, and recorded) to test_a_source_that_is_no_top_level_is_refused_by_name.

F2. A .git repository written in another case still reaches gh. This is L1-100 in a different case. _github_name compares repo == ".git" and strips the .git suffix case-sensitively.

merge: --source testorg/.GIT                    -> gh [repo] [clone] [testorg/.GIT] […/source-readonly/.GIT]
merge: --source testorg/.Git.git                -> gh [repo] [clone] [testorg/.Git] […/source-readonly/.Git]
merge: --source https://github.com/testorg/.GIT -> gh [repo] [clone] [testorg/.GIT] […]
  • GitHub matches repository names without regard to case, so this is the same name the PR refuses for owner/.git.
  • On a case-insensitive file system (macOS, Windows), the target source-readonly/.GIT is source-readonly/.git.

The fix is repo.lower() == ".git" in place of repo == ".git", plus a dot-git-upper-case row (testorg/.GIT, NEITHER) in NAMES_NO_REPOSITORY.

F3 (low; the coordinator may move it to the checklist). A recorded local_path: "" is still treated as absent, and the plan's repository is cloned from GitHub. This is the same cause as L1-96's if override:. open_source tests if local_path:.

plan's source edited to  local_path: ""  cloned: false  repository: testorg/Thing
check --plan P   main and merge: EXIT=0
  gh [repo] [clone] [testorg/Thing] […/source-readonly/Thing]

This contradicts _recorded_source's own rule, "ONLY A CLONE plan MADE IS MADE AGAIN". It also contradicts #652's N4: a repository read from a gitlab clone's origin would be cloned from github.com. A fix: in open_source, refuse local_path == "" with self._gone("", "names no path", "…") before the if local_path: test.

Checked and sound

  • Refusal order. I ran 8 bad sources through each of the 3 commands, once with a --work-dir that did not exist and once with one already holding mine.txt and source-readonly/Thing/k.txt. The sources were a file, src, a symlink to src, '', ~nouser/x, testorg/.git, https://github.com/testorg/.git.git and a gitlab url.
  • Byte for byte against main.
    • testorg/Thing and https://github.com/testorg/Thing.git: stdout, stderr, the plan and the gh arguments are identical.
    • An absolute path, . at the top level and a relative symlink: the plan is identical, and stdout differs only in the intended source line.
    • check of a local plan and of a cloned plan: identical (1900 B and 1989 B). execute, up to adopt-unconfirmed: identical.
  • URL spellings. Each of these gives gh exactly testorg/Thing and records the same repository: testorg/Thing: org/repo, .git, user:pw@, ssh://, scp, HTTPS://GITHUB.COM, bare GitHub.com/, and the 8 www. forms (with a scheme, bare, in upper case, scp, ssh, and with :443).
    • The following are refused as on main, with no gh call:
      • %2F;
      • a query string;
      • a #fragment;
      • /tree/main;
      • www.gitlab.com;
      • www2.github.com;
      • www.github.com.;
      • www.github.com.evil.example;
      • www.github.com:org/repo.
  • Linked worktree, bare repository and submodule checkout are each read as the top level of their own repository. An uninitialised submodule directory ended main in a RecursionError (ask_about_submodules → _own_entries). It is now refused source-not-top-level, naming the superproject.
  • Trailing slashes and ... src/.. plans the top level, src/ is refused, and src/../.. is refused git-failed.
  • Lost blob.
    • The refusal fires at depth 5 (src/deep/one/two/c.txt), for a symlink's blob and for .gitattributes.
    • A lost tree gives git-failed: … exited 1: error: Could not read 7ba37a43…, with no traceback on either side.
    • git fsck there prints missing blob 4cdb2265…, so the remedy is right.
    • A blob-less partial clone whose promisor cannot be reached exits 128 at ls-tree. It gets git-failed in git's words, not the fsck remedy, which is right because git fsck exits 0 there.
  • Recorded cloned: true. A recorded README.md or src gets adopt-project.py: --source is read by its spelling, an org/repo in any spelling is cloned by its name, the plan records source.cloned, and a clone no plan records is removed, so a recorded clone that is gone or emptied is refused or cloned again for what it is (#652) #749's NOTE ("not a directory", "git reads it as part of the repository at") and is cloned again; that behaviour is unchanged.
  • Tests.
    • I reproduced six rows of the revert table exactly: the not-a-directory check (6 fail), the --show-prefix check (7), .git in _github_name (2), the Source.tree size check (3), _source_spec before _work_root (4) and override is not None (2).
    • The head's two test files run against main's adopt-project.py give 36 failed, 80 passed.
    • My gate on the merge, with git-filter-repo on PATH, ran test_adopt_source_clone_record.py, test_adopt_work_dir_refusal.py, test_shape_doctor.py, test_scaffold_leg_path_collisions.py, test_cli_reference.py and test_agents_md_codes.py: 726 passed, 3 skipped, 1 xfailed in 626.82s, EXIT=0, head 3a0386b8, dirty 0.
  • CI. tests, tests-git-bash and package pass. SonarCloud's Quality Gate passed: 0 new issues and 0.0% duplication on new code. The only annotations are the Node 20 and ubuntu-label notices.
  • Windows. The os.path.expanduser claim holds. No new test reads back bytes it wrote with write_text, and every name it makes is legal on NTFS.

Exact text replacements

  1. Known limits, second bullet. If F1 is fixed, delete it. If not, replace it with: "A directory inside a repository's git directory (<repo>/.git/objects, <bare>.git/refs) has an empty --show-prefix, so plan still plans the whole repository from it, exit 0, and records that directory as local_path; check then refuses git-failed at git ls-remote of it and execute adopt-remote-unreadable, as on main. <repo>/.git itself and a bare repository's own directory are read whole, and work."
  2. Tests. Replace "Written first: on main bf1f099b the 36 new cases failed and the 78 existing ones passed." with: "Written first: run against main's adopt-project.py, the 36 new cases fail, and the 76 existing cases and the 4 new controls pass (36 failed, 80 passed)."
  3. Tests. Replace "Each asserts the exit, the whole refusal, no plan written, no leg repository, and no gh call." with: "Each asserts the exit and the refusal (whole where its words are fixed; its code, first line or named parts where it holds a path or an object id), no plan written, no leg repository and no gh call; the --work-dir cases assert the code's prefix, no plan written, and that the directory was never made."
  4. If F2 is fixed.
    • In the L1-100 row, replace "owner/.git, https://github.com/owner/.git.git" with "owner/.git in any case, https://github.com/owner/.git.git".
    • In How, replace "_github_name refuses the repository .git (owner/.git, owner/.git.git)." with "_github_name refuses the repository .git in any case (owner/.git, owner/.GIT, owner/.git.git)."
  5. Title. It stands if F1 is fixed. If not, replace "that names no repository's top level" with "that is a file or a directory inside a repository's work tree".

For the checklist (#457)

  • owner/..., or any repository segment that ends in ., still reaches gh, as on main. On Windows, Win32 drops trailing dots, so source-readonly/... names source-readonly itself.
  • Both _github_name and url_repository strip .git case-sensitively and only once. So testorg/Thing.GIT is cloned and recorded as testorg/Thing.GIT, and Thing.git.git as Thing.git.
  • A directory that is no repository is still refused as git-failed: git rev-parse --show-prefix … not a git repository. The refusal has no remedy and names neither --source nor the top-level rule. On main it named git rev-parse main.
  • A plain directory inside an unrelated work tree is refused naming the outer repository, whose top level the remedy then offers. An example is a home directory kept as a dotfiles repository.
  • The uninitialised-submodule RecursionError that this PR fixes in passing has no test pinning it.
  • In a blob-less partial clone, git ls-tree -l fetches every blob from the promisor over the network, as on main.
  • The writer's two items still stand: the chmod 000 PermissionError, and check's "WHAT WILL HAPPEN, against " line.

Lane: openreposhape-1 (openRepoShape-1)

…tory in any case and an empty recorded `local_path` are refused too, so `.git/objects` is no longer planned as the repository, `owner/.GIT` no longer reaches `gh`, and `local_path: ""` no longer clones the plan's repository from GitHub (#796)

The three findings of #831's review (r796):
- F1. Inside a git directory `git rev-parse --show-prefix` prints
  nothing, so `<repo>/.git/objects` and `<bare>.git/refs` passed
  `_top_level`: `plan` planned the whole repository from them, exit 0,
  and `check` and `execute` then failed as L1-88 did. `_top_level` asks
  `--is-inside-git-dir` first, and refuses `source-not-top-level`,
  naming the git directory, for any directory below one; a git directory
  itself (`<repo>/.git`, a bare repository, `.git/modules/<m>`,
  `.git/worktrees/<w>`) is read whole, as on main.
- F2. `_github_name` compared `repo == ".git"` and dropped a trailing
  `.git` in that case alone, so `owner/.GIT` and `owner/.Git.git`
  reached `gh`. Both are now read without regard to case, as GitHub
  reads a repository's name.
- F3. `Plan.open_source` tested `if local_path:`, so `local_path: ""`
  was read as none and the plan's `source.repository` was cloned from
  GitHub, which `_recorded_source` never does for a path the person
  gave. It is refused `plan-source-gone`, "names no path", before that
  test; `_gone`'s way out is the constant `PLAN_SOURCE_GONE_REMEDY`.

Tests: `.git/objects` given and recorded, `<bare>.git/refs`, the three
git directories read whole as controls, `.GIT` and `.Git.git` refused,
`Thing.GIT` cloned as `testorg/Thing`, and `local_path: ""` under
`check` and `execute`.

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-17T20:07:22Z) Lane 1's re-verification of #831 at 2d51f18: PR-READY

The head is 2d51f1811a89, one commit on adbc5e37 that touches only adopt-project.py and tests/test_adopt_source_clone_record.py. I merged it locally with current main fc867207, giving 7ebf0b74; the merge is clean. Every probe ran with a stub gh that logs its arguments, GH_TOKEN=bogus-r796, an empty GH_CONFIG_DIR, GIT_CONFIG_GLOBAL=/dev/null and GIT_ALLOW_PROTOCOL=file. All three findings are resolved. The one condition is that tests was still running at 20:05Z: it started at 20:00:02Z, and the last run took 58 minutes.

F1, a directory inside a git directory: resolved

plan --source main fc867207 merge
<repo>/.git/objects EXIT=0, plans 10 files EXIT=2 REFUSED source-not-top-level: --source '…/Thing/.git/objects' is inside the git directory …/Thing/.git, and a source is the top-level directory of a repository
<repo>/.git/refs, <bare>.git/refs, super/.git/modules/sub/refs EXIT=0 EXIT=2 source-not-top-level, no gh call

The same path recorded as local_path:

  • On main, check exits 2 with a git or gh command failed (at git ls-remote), and execute with adopt-remote-unreadable.
  • On the merge, both exit 2 with source-not-top-level: the plan's `source.local_path`, `…/.git/objects`, is inside the git directory `…/.git`, ….

The controls still plan, EXIT=0 on both sides: <repo>/.git, bare.git, .git/worktrees/wt (reads wt-branch), super/.git/modules/sub, the top level, a linked worktree and a submodule checkout.

F2, .git in another case: resolved

  • testorg/.GIT, testorg/.Git.git and https://github.com/testorg/.GIT:
    • On main, gh is called with testorg/.GIT or testorg/.Git.
    • On the merge, they exit 2 source-unresolvable with no gh call.
  • testorg/Thing.GIT and https://github.com/testorg/Thing.Git:
    • On main, gh is called with the name as typed.
    • On the merge, they give gh <testorg/Thing> and record testorg/Thing.
  • testorg/.github and testorg/.gitx still reach gh as typed on both sides, which is right.

F3, a recorded local_path: "": resolved

  • With cloned: false, check and execute on main called gh repo clone testorg/Thing; check exited 0, and execute went on to adopt-unconfirmed.
  • On the merge, both exit 2 with REFUSED plan-source-gone: the plan's `source.local_path` is empty: it names no path, and an empty path would be read as the directory this runs in, and there is no gh call.
  • Note: with cloned: true, the empty value is also refused, where main cloned it again. That follows the coordinator's ruling, and the remedy offers the org/repo way out. No plan writes this value, so only a hand-edited plan can hold it.

Nothing else changed

  • The one change outside the three fixes moves _gone's remedy into PLAN_SOURCE_GONE_REMEDY. I compared the parsed strings, and the text is unchanged.
  • For plan of testorg/Thing and of https://github.com/testorg/Thing.git, stdout, stderr, the plan written and the gh arguments are byte-identical to main.
  • For plan of a local top level, the plan is identical; stdout differs only in the intended source line.
  • check of a local plan and of a cloned plan is byte-identical to main (1900 B and 1992 B).

Tests

  • New revert rows, re-run on the head over both test files (130 tests). Each fails as the writer's table says:
    • The git-directory check: 6 failed. These are the git-directory-{plan,check,execute} cases, the recorded git-directory-{check,execute} cases, and test_a_directory_inside_a_bare_repository_is_refused.
    • The empty local_path refusal: 2 failed, test_an_empty_recorded_local_path_is_refused_and_never_cloned[check,execute].
    • .git compared in one case only: 2 failed, [dot-git-upper-case, dot-git-mixed-case].
  • The head's tests against main's adopt-project.py: 47 failed, 83 passed, which matches the body.
  • On the merge: tests/test_adopt_source_clone_record.py, tests/test_adopt_work_dir_refusal.py and tests/test_cli_reference.py give 153 passed, EXIT=0, dirty 0.

CI and Sonar at 2d51f18

  • SonarCloud Code Analysis: Quality Gate passed, 0 new issues, 0 security hotspots, 0.0% duplication on new code.
  • package and tests-git-bash pass. Their only annotations are the Node 20 and ubuntu-label notices.
  • tests: in progress, as noted at the top; it must be green before landing.

The body

  • It is true at the head. It is 17,605 characters, and has the Lane and Claim lines, Closes #796 and the robot line, which is now last.
  • Every claim I checked holds:
    • the F1, F2 and F3 rows;
    • the "47 failed, 83 passed" sentence;
    • the revert rows I re-ran;
    • the controls, including .git/modules/<m>, which the F1 row names and I probed;
    • the url_repository line under "Not in this PR".
  • I have no replacements. The title stands now that F1 is fixed.

For the checklist

These are carried from the first review:

  • owner/... still reaches gh, and on Windows source-readonly/... names source-readonly itself.
  • A directory that is not a repository is still refused git-failed with no remedy.
  • The uninitialised-submodule RecursionError that this PR fixes in passing has no test pinning it.
  • A blob-less partial clone fetches its blobs over the network, as on main.
  • The writer's two items still stand: the chmod 000 PermissionError, and check's "WHAT WILL HAPPEN" line.

New in this round: a local source whose origin ends Thing.GIT is still recorded as testorg/Thing.GIT by url_repository, while _github_name now reads --source testorg/Thing.GIT as testorg/Thing. The body states this.

Lane: openreposhape-1 (openRepoShape-1)

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-18T04:05:30Z) Lane 1's re-verification of #831 at db202f5: PR-READY

All three behaviours hold at the merge head: #831's own, main's #830 (#797) and main's #833 (#799). The one condition is that tests at db202f53 was still running at 04:04Z; it must finish green before landing.

What I tested:

  • Head db202f537c89, whose parents are 2d51f181 (the head I found PR-READY) and main 3fb30a8f.
  • My local-only merge with current main d7befb91 is clean: commit 60e15be9, tree 7adb3c79, the same tree as your merge-tree.
  • Compared with d7befb91, the PR changes only adopt-project.py, docs/cli.md, tests/test_adopt_source_clone_record.py and tests/test_adopt_work_dir_refusal.py.
  • Every probe ran with a stub gh that logs its arguments, GH_TOKEN=bogus-r796, an empty GH_CONFIG_DIR, GIT_CONFIG_GLOBAL=/dev/null, GIT_ALLOW_PROTOCOL=file and GIT_SSH_COMMAND=false.

1. Each hunk keeps both sides

I read the merge's conflict resolution with git show --remerge-diff db202f53. All eight hunks match the writer's report:

The only code lines the PR removes from main are #796's own replacements:

  • Path(spec).expanduser();
  • path.expanduser().resolve();
  • if override:;
  • size.strip();
  • the old source line;
  • the Source.open(...) call in cmd_plan;
  • the two unguarded in_place(path, …) calls in _recorded_source.

Main's _refuse_an_unwritable_out and _write_the_plan are intact.

Ordinary inputs, byte for byte against main d7befb91:

  • plan of testorg/Thing and of https://github.com/testorg/Thing.git: stdout, stderr, the plan written and the gh arguments are identical.
  • plan of a local top level: the plan is identical, and stdout differs only on the intended source line (now line 3): fx/Thing (origin: testorg/Thing) @ … against testorg/Thing @ ….
  • check of a local plan and of a cloned plan: identical, 1900 B and 1992 B.

2. The writer's open points

Hunk 4's order holds. I ran plan with an unwritable --out and a source on both sides; in every case the --work-dir was not made and gh was not called.

--out --source main d7befb91 merge
a folder README.md adopt-out-unwritable adopt-out-unwritable: --out `outdir` cannot be written: it is a folder, and the plan is one file
a missing folder src adopt-out-unwritable adopt-out-unwritable
a folder testorg/.GIT adopt-out-unwritable adopt-out-unwritable
a folder top level adopt-out-unwritable adopt-out-unwritable
writable README.md exit 1, traceback, --work-dir made exit 2 source-not-top-level, --work-dir not made

--tracking-branch and read_at still reach the read. The source was a clone made with -b dev: origin/HEAD is refs/remotes/origin/main, and the only local branch is dev. Main and the merge behave the same:

  • plan with no --tracking-branch: exit 2, with the git branch main origin/main remedy.
  • plan --tracking-branch dev: exit 0, reading 0dc722918e12 (dev), 11 files, 2 commits. The plan records default_branch: dev and tracking_branch: dev.
  • check of that plan reads against testorg/Thing @ 0dc722918e12, whether from the recorded top-level path or with --source given.
  • execute, recorded or with --source, gets past opening the source to adopt-unconfirmed. Without read_at it would be refused there as no branch of its own.
  • The clone path passes instead on to _at; I read that in the code.

Revert rows at db202f53. Each row is one change reverted alone, run over both #796 test files (130 tests). All three match the writer's table:

  • M2, the --show-prefix check: 7 failed.
    • [directory-plan, directory-check, directory-execute];
    • recorded [directory-check, directory-execute];
    • test_dot_in_a_directory_inside_the_repository_is_refused;
    • test_plan_refuses_a_source_before_it_makes_the_work_dir[a-directory-inside].
  • M13, _top_level on a recorded path: 6 failed. Recorded [file, directory, git-directory] × [check, execute].
  • M14, the git-directory check: 6 failed.
    • [git-directory-plan, -check, -execute];
    • recorded [git-directory-check, -execute];
    • test_a_directory_inside_a_bare_repository_is_refused.

plan --source after the merge.

--source main d7befb91 merge
<repo>/.git/objects exit 0, 10 files exit 2, source-not-top-level: --source '…/Thing/.git/objects' is inside the git directory `…/Thing/.git`, …
<bare>.git/refs exit 0 exit 2 source-not-top-level
super/.git/modules/sub/refs exit 0 exit 2 source-not-top-level
README.md exit 1 NotADirectoryError exit 2 source-not-top-level, "is not a directory"
src exit 0, 3 files exit 2 source-not-top-level, is `src/` inside the repository at …
'' exit 2 git-failed (git's own words) exit 2 source-unresolvable, "names no path"
~no-such-user-796/x exit 1 RuntimeError exit 2 source-unresolvable
testorg/.git, testorg/.GIT, testorg/.Git.git gh repo clone testorg/.git, .GIT or .Git exit 2 source-unresolvable, no gh call
https://www.github.com/testorg/Thing exit 2 exit 0, gh <testorg/Thing>

Controls still plan, exit 0 on both sides:

  • <repo>/.git;
  • bare.git;
  • the worktree's git directory, .git/worktrees/wt, which reads wt-branch;
  • the submodule's git directory, .git/modules/sub;
  • the top level, a linked worktree and a submodule checkout.

Recorded local_path, check and execute. No gh call on the merge in any of these.

Recorded local_path main merge
.git/objects check: "a git or gh command failed"; execute: adopt-remote-unreadable both source-not-top-level, the plan's `source.local_path`, `…/.git/objects`, is inside the git directory …
src check: "a git or gh command failed"; execute: plan-uncovered both source-not-top-level
"" gh repo clone testorg/Thing; check exits 0 and execute goes on to adopt-unconfirmed both plan-source-gone: the plan's `source.local_path` is empty: …

3. Targeted gate on the local merge 60e15be9, dirty 0

The run used /usr/bin/python3 -m pytest, without GIT_ALLOW_PROTOCOL, with the stub gh, GH_TOKEN=bogus-r796 and an empty GH_CONFIG_DIR. The files:

Mode Result Exit
git-filter-repo on PATH 869 passed, 3 skipped in 1006.77s 0
git-filter-repo hidden (PATH=<stub>:/usr/local/bin:/usr/bin:/bin, so command -v git-filter-repo printed nothing) 853 passed, 19 skipped in 854.48s 0

4. The body

  • The posted body is the writer's draft, apart from a trailing newline. It is 18,822 characters. It has the Lane and Claim lines and Closes #796, and the robot line is last.
  • The "Merges of main" section is true against the code, bullet by bullet: efbb6796's merge of 24a977c0 had no conflict; db202f53 merged 3fb30a8f with eight conflict hunks in adopt-project.py, and docs/cli.md merged by itself; and the four resolution bullets match the hunks above.
  • The Order paragraph is true as measured above: the --pin-source refusals and --out (adopt-out-unwritable) come before --source.
  • The dated statements are true as written:
    • the repro on main 24a977c0;
    • "47 failed, 83 passed" against 24a977c0;
    • the revert table at 2d51f181; the rows I re-ran at db202f53 give the same counts.
  • I have no text replacements.

CI at db202f5

  • SonarCloud Code Analysis: Quality Gate passed, 0 new issues, 0 security hotspots, 0.0% duplication on new code.
  • package and tests-git-bash pass. Their only annotations are the Node 20 and ubuntu-label notices.
  • tests started at 03:25:41Z and was still running at 04:04Z. The previous head 2d51f181's tests finished success at 2026-10-17T20:59:09Z.

Nothing of mine is still running. I pushed nothing and posted nothing.

Next (lane 1's coordinator): #831 joins lane 1's v1.1.1 queue after #849, PR-READY at db202f53, on condition that CI tests at that head finishes green. At its turn, the pilot predicts and gates the squash over the main of that moment, and lands it under Brett Heap's word "merge lane 1's PR-READY drafts after the v1.1.0 tag" (2026-10-17T21:47:34Z).

Lane: openreposhape-1 (openRepoShape-1)

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap marked this pull request as ready for review October 18, 2026 04:44
@sourcery-ai

sourcery-ai Bot commented Oct 18, 2026

Copy link
Copy Markdown

Sorry @brettheap, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 3 days and 15 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit 3341992 into main Oct 18, 2026
4 of 5 checks passed
@brettheap
brettheap deleted the fix/796-adopt-source-spellings branch October 18, 2026 04:44
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>
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