Repository navigation
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
Conversation
…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>
…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>
Reviewer's GuideThe 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 handlingsequenceDiagram
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
Sequence diagram for recorded source validationsequenceDiagram
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
Flow diagram for source validation and openingflowchart 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
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
(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 The issue's lines (main
|
| 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/objectsandbare.git/refsexit 2source-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.pyandtest_adopt_plan_escapes.pygive 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/.GITissource-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-dirthat did not exist and once with one already holdingmine.txtandsource-readonly/Thing/k.txt. The sources were a file,src, a symlink tosrc,'',~nouser/x,testorg/.git,https://github.com/testorg/.git.gitand a gitlab url.- All 48 runs exited 2 and made no
ghcall. planmade nothing.checkandexecutemade only the new--work-dir, which is the accepted exception. The full work directory and the source were unchanged, by path listing.- On main,
planmade the--work-direvery time, andexecute testorg/.gitmadesource-readonly/and calledgh. - The full work directory's mtime changes on main and on the merge alike; that is adopt-project.py
check: refuse a kept file at the shape's pin path, and an unwritable--work-dir, before any leg exists #524's writability probe.
- All 48 runs exited 2 and made no
- Byte for byte against main.
testorg/Thingandhttps://github.com/testorg/Thing.git: stdout, stderr, the plan and thegharguments are identical.- An absolute path,
.at the top level and a relative symlink: the plan is identical, and stdout differs only in the intendedsourceline. checkof a local plan and of a cloned plan: identical (1900 B and 1989 B).execute, up toadopt-unconfirmed: identical.
- URL spellings. Each of these gives
ghexactlytestorg/Thingand records the samerepository: testorg/Thing:org/repo,.git,user:pw@,ssh://, scp,HTTPS://GITHUB.COM, bareGitHub.com/, and the 8www.forms (with a scheme, bare, in upper case, scp, ssh, and with:443).- The following are refused as on main, with no
ghcall:%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.
- The following are refused as on main, with no
- 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 refusedsource-not-top-level, naming the superproject. - Trailing slashes and
...src/..plans the top level,src/is refused, andsrc/../..is refusedgit-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 fsckthere printsmissing blob 4cdb2265…, so the remedy is right.- A blob-less partial clone whose promisor cannot be reached exits 128 at
ls-tree. It getsgit-failedin git's words, not the fsck remedy, which is right becausegit fsckexits 0 there.
- The refusal fires at depth 5 (
- Recorded
cloned: true. A recordedREADME.mdorsrcgets adopt-project.py:--sourceis read by its spelling, anorg/repoin any spelling is cloned by its name, the plan recordssource.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-prefixcheck (7),.gitin_github_name(2), theSource.treesize check (3),_source_specbefore_work_root(4) andoverride 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.pyandtest_agents_md_codes.py:726 passed, 3 skipped, 1 xfailed in 626.82s, EXIT=0, head3a0386b8, dirty 0.
- I reproduced six rows of the revert table exactly: the not-a-directory check (6 fail), the
- CI.
tests,tests-git-bashandpackagepass. 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.expanduserclaim holds. No new test reads back bytes it wrote withwrite_text, and every name it makes is legal on NTFS.
Exact text replacements
- 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, soplanstill plans the whole repository from it, exit 0, and records that directory aslocal_path;checkthen refusesgit-failedatgit ls-remoteof it andexecuteadopt-remote-unreadable, as on main.<repo>/.gititself and a bare repository's own directory are read whole, and work." - Tests. Replace "Written first: on main
bf1f099bthe 36 new cases failed and the 78 existing ones passed." with: "Written first: run against main'sadopt-project.py, the 36 new cases fail, and the 76 existing cases and the 4 new controls pass (36 failed, 80 passed)." - Tests. Replace "Each asserts the exit, the whole refusal, no plan written, no leg repository, and no
ghcall." 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 noghcall; the--work-dircases assert the code's prefix, no plan written, and that the directory was never made." - If F2 is fixed.
- In the L1-100 row, replace "
owner/.git,https://github.com/owner/.git.git" with "owner/.gitin any case,https://github.com/owner/.git.git". - In How, replace "
_github_namerefuses the repository.git(owner/.git,owner/.git.git)." with "_github_namerefuses the repository.gitin any case (owner/.git,owner/.GIT,owner/.git.git)."
- In the L1-100 row, replace "
- 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 reachesgh, as on main. On Windows, Win32 drops trailing dots, sosource-readonly/...namessource-readonlyitself.- Both
_github_nameandurl_repositorystrip.gitcase-sensitively and only once. Sotestorg/Thing.GITis cloned and recorded astestorg/Thing.GIT, andThing.git.gitasThing.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--sourcenor the top-level rule. On main it namedgit 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
RecursionErrorthat this PR fixes in passing has no test pinning it. - In a blob-less partial clone,
git ls-tree -lfetches every blob from the promisor over the network, as on main. - The writer's two items still stand: the
chmod 000PermissionError, andcheck'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>
|
(lane openRepoShape-1, 2026-10-17T20:07:22Z) Lane 1's re-verification of #831 at 2d51f18: PR-READY The head is F1, a directory inside a git directory: resolved
The same path recorded as
The controls still plan, EXIT=0 on both sides: F2,
|
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>
|
(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 What I tested:
1. Each hunk keeps both sidesI read the merge's conflict resolution with
The only code lines the PR removes from main are #796's own replacements:
Main's Ordinary inputs, byte for byte against main
2. The writer's open pointsHunk 4's order holds. I ran
Revert rows at
Controls still plan, exit 0 on both sides:
Recorded
3. Targeted gate on the local merge
|
| 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 of24a977c0had no conflict;db202f53merged3fb30a8fwith eight conflict hunks inadopt-project.py, anddocs/cli.mdmerged by itself; and the four resolution bullets match the hunks above. - The Order paragraph is true as measured above: the
--pin-sourcerefusals 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 atdb202f53give the same counts.
- the repro on main
- 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.packageandtests-git-bashpass. Their only annotations are the Node 20 and ubuntu-label notices.testsstarted at 03:25:41Z and was still running at 04:04Z. The previous head2d51f181'stestsfinishedsuccessat 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)
|
|
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 |
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>
…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>



Lane: openreposhape-1 (openRepoShape-1)
Claim: #796 (comment)
What
adopt-project.pynow refuses, by name and with a way out, every--source(and every recordedsource.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 anyghcall.plannow reads--sourcebefore it makes its work directory. Each line of #796, and the three findings of this PR's review (r796):source-not-top-level(new): "--source '…/Thing/README.md' is not a directory, and a source is the top-level directory of a repository".run in onesource-not-top-level: "--source '…/Thing/src' issrc/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 undersrc/"<repo>/.git/objects,<bare>.git/refssource-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.source.local_pathis any of thosesource-not-top-level, named by its key: "the plan'ssource.local_path,…/Thing/src, issrc/inside …"sourcereport lineoriginnames beside it:source …/Thing (origin: testorg/Thing) @ …. A cloneplanmade is still named by its repository.~nouser/xsource-unresolvable: "--source '~nouser/x' is neither a path that exists nor anorg/reponame". Recorded, it is a path that is gone:plan-source-gone, or cloned again where the plan recordscloned: true.--source ''(or"$SRC"withSRCunset)source-unresolvable: "--source '' names no path: an empty one would be read as the directory this runs in".checkandexecuterefuse it too; they no longer drop it.source.local_pathis""plan-source-gone: "the plan'ssource.local_pathis empty: it names no path, and an empty path would be read as the directory this runs in". Nothing is cloned.owner/.gitin any case,https://github.com/owner/.git.gitsource-unresolvable("is neither a path that exists nor anorg/reponame"; "names noowner/repoon github.com"), and noghcallhttps://www.github.com/o/r,www.github.com/o/rgithub.com: cloned aso/rgit-failed: "git ls-tree -r -l -z <commit>in gives no size forREADME.md, whose blob it cannot read: it printsBAD", with "Remediation: rungit fsckin : it names each object the repository lacks. …"source-not-top-level's way out, for a--sourceand a recorded path alike: "Remediation: pass --source the repository's top-level directory, whichgit rev-parse --show-toplevelprints in any directory of it, or itsorg/repo."The new code is named in
plan --source's help and incheck's andexecute's--sourcehelp, anddocs/cli.mdis regenerated from them.Why (repro on main)
Probed on main
24a977c0with a stubghfirst on PATH,GIT_CONFIG_GLOBAL=/dev/nulland a 10-file source whoseoriginishttps://github.com/testorg/Thing.git:plan --source <repo>/README.md --work-dir Wended inNotADirectoryError: [Errno 20] Not a directory: '…/Thing/README.md', exit 1, andWhad been made and stayed.check --sourceandexecute --sourceend the same way: the traceback runsSource.in_place→_remote_repository→_origin_url→git_out.plan --source <repo>/srcexited 0:source testorg/Thing @ 2519526bac4e (main), 4 files, 1 commits, withlocal_path: "…/Thing/src".plan --source .run in<repo>/docsexited 0 with 2 files.checkof that plan was refuseda git or gh command failed;executewas refused over what the subtree lacked (adopt-empty-leg-unconsentedfor this tree; the issue measuredadopt-remote-unreadablefor another). Thesourceline namedtestorg/Thingwhatever directory was read.bad05d21).plan --source <repo>/.git/objectsexited 0, planning the whole repository and recordinglocal_path: "…/Thing/.git/objects";checkwas then refuseda git or gh command failedatgit ls-remoteof it, andexecuteadopt-remote-unreadable.<bare>.git/refsbehaved the same.plan --source '~nouser/x'ended inRuntimeError: Could not determine home directory., exit 1, fromPath(spec).expanduser(); a plan recording that path did the same atPlan._recorded_source.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 tolocal_path: "",cloned: false,repository: testorg/Thingmadecheckexit 0 aftergh repo clone testorg/Thing: the plan's repository was cloned from GitHub, which_recorded_sourcenever does for a path the person gave.plan --source owner/.gitrangh repo clone owner/.git …/source-readonly/.gitand was refusedsource-unclonable, with a remedy aboutgh auth status. (r796)testorg/.GIT,testorg/.Git.gitandhttps://github.com/testorg/.GITreachedghas well.https://www.github.com/testorg/Thingwas refused as "a repository on www.github.com";www.github.com/testorg/Thingas "neither a path that exists nor anorg/reponame".git ls-tree -r -lprintsBADfor its size and exits 0, andplanended inValueError: 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). Thengit 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. Thengit rev-parse --show-prefixmust 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-toplevelasked, for the refusal to name. A directory git reads no repository from is still refusedgit-failed, in git's own words, as a later read refused it on main._source_specrefuses an empty value first, in the manner ofpin-source-no-path. It expands~withos.path.expanduser, which leaves a~userthis machine has not as typed, and it hands every path that exists to_top_level, returning the resolved top level.Plan._recorded_sourceusesos.path.expanduser, and holds a path the person gave (clonednottrue) to_top_level, naming it by its key. A cloneplanmade keeps adopt-project.py:--sourceis read by its spelling, anorg/repoin any spelling is cloned by its name, the plan recordssource.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_cloneand a fresh clone.Plan.open_sourcetestsoverride is not None, so an empty--sourcereaches_source_specand is refused; and it refuses alocal_pathof""asplan-source-gonebefore itsif local_path:test._gone's way out is now the constantPLAN_SOURCE_GONE_REMEDY, which both use.Source.openis now_source_specthenSource.of, andcmd_plancalls_source_specbefore_work_root, soplanmakes no work directory for a refused--source._github_namerefuses the repository.gitin any case (owner/.git,owner/.GIT,owner/.git.git), and drops a trailing.gitin any case, as GitHub reads a repository's name without regard to case:testorg/Thing.GITis cloned astestorg/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 intosource-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 pinnedscripts/repo_shape.py, is unchanged.SOURCE_URL_REreadswww.github.com/with no scheme, and_repository_in_urlreads the hostwww.github.comasgithub.com.Source.treerefuses a size that is neither-nor ASCII digits, asgit-failed, naming the path, the blob andgit fsck._source_named, new, writes thesourceline's name: the clone's repository, or the directory read with(origin: <repo>)beside it.Order, and one deliberate exception. In
planevery--sourcerefusal now comes before the work directory is made, before any clone and before anyghcall; for a local path onlygit rev-parsehas run. Only the flagsplanasks first come before it: the--pin-sourcerefusals and, since #833 (#799),--out(adopt-out-unwritable), which need no process. Incheckandexecutethe--sourcerefusals come before any other git, any clone and anyghcall, but AFTER the work directory is asked: #650 put the work directory first on purpose, andtests/test_adopt_work_dir_refusal.py::test_check_refuses_a_source_it_cannot_open_as_execute_doespins that order. So incheckandexecutea--work-dirthat did not exist yet is made before a bad--sourceis refused, as it is for every other--sourcerefusal there on main. Accepted by the lane-1 coordinator.Tests
tests/test_adopt_source_clone_record.pyandtests/test_adopt_work_dir_refusal.pyare extended. Written first: run against main'sadopt-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 controlsdot-dot-git-repository,url-dot-dot-repository; the--helptest:source-not-top-levelfor all three commands andsource-unresolvableforplan).New tests, each for
plan,checkandexecutewhere it applies: a file, a directory inside the repository and.git/objects, each given as--sourceand recorded aslocal_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); thesourceline;~no-such-user-796/Thinggiven and recorded, and a recorded clone under it cloned again;--source ''run inside the repository; a recordedlocal_path: ""; a lost blob; andplangiven each of a file, a directory inside,''and an unknown~userwith a--work-dirthat 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 noghcall; the--work-dircases assert the code's prefix, no plan written, and that the directory was never made.Revert table. Each change reverted alone in
adopt-project.pyat2d51f181, running both files (130 tests; 153 withtests/test_cli_reference.py, added for the help row):_top_level: a path that is not a directorytest_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)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-prefixthat is not emptytest_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 valuetest_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 toif override:)test_an_empty_source_names_no_path_and_is_refused[check,execute]Plan.open_source: the emptylocal_pathtest_an_empty_recorded_local_path_is_refused_and_never_cloned[check,execute]_source_spec:os.path.expanduser(back toPath.expanduser)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.expandusertest_a_recorded_path_under_an_unknown_user_is_gone[check,execute],test_a_recorded_clone_under_an_unknown_user_is_cloned_againPlan._recorded_source:_top_levelfor a path the person gavetest_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, removedtest_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:.gitcompared in its case alonetest_a_source_that_names_no_github_repository_never_reaches_gh[dot-git-upper-case,dot-git-mixed-case]_github_name: a trailing.gitdropped in its case alonetest_each_spelling_of_a_name_is_cloned_and_recorded_as_owner_repo[dot-git-upper-case]_repository_in_url: the hostwww.github.comtest_each_spelling_of_a_name_is_cloned_and_recorded_as_owner_repo[www-url,www-no-scheme]SOURCE_URL_RE:www.with no schemetest_each_spelling_of_a_name_is_cloned_and_recorded_as_owner_repo[www-no-scheme]Source.tree: the size checktest_a_source_that_lost_a_blob_is_refused_naming_it_and_git_fsck[plan,check,execute]_source_named: thesourceline (back to the repository alone)test_the_source_line_names_the_directory_read_beside_its_repositorycmd_plan:_source_specbefore_work_root(back to after it)test_plan_refuses_a_source_before_it_makes_the_work_dir[a-file,a-directory-inside,empty,unknown-user]--sourcehelp textstest_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 intests/test_cli_reference.py(the committeddocs/cli.mdno 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 filesgrep -l -E 'adopt-project|adopt_project|\bADOPT\b' tests/*.pynames, plustests/test_shape_doctor.py,tests/test_scaffold_leg_path_collisions.py,tests/test_cli_reference.pyandtests/test_agents_md_codes.py, 102 files:git filter-repoon PATHgit filter-repohidden (PATH=<stub gh>:/usr/local/bin:/usr/bin:/bin)The 102nd is
tests/conftest.py, which the grep names because it definesADOPT: 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 stubghfirst on PATH,GH_TOKEN=bogus-w796, an emptyGH_CONFIG_DIR,GIT_CONFIG_GLOBAL=/dev/nullandGIT_SSH_COMMAND=false.Merges of main
efbb6796merged main24a977c0with no conflict.db202f53merged main3fb30a8f(adopt-project.py:_default_branchreadsorigin/HEADand HEAD by full name and whole, and reads the source only at a branch of its own, so agit clone -bsource is no longergit-failedwith no remedy, and a HEAD onorigin/x, onmain<U+00A0>or beside a tagmainno longer adopts another branch's commit (#797) #830 for adopt-project.py:_default_branchreadsorigin/HEADand HEAD by short name, so a source cloned withgit clone -b <branch>is refusedgit-failedwith no remedy, and a HEAD on a localorigin/xor on a name ending in U+00A0 adopts another branch's commit #797, and adopt-project.py:planasks whether it can write--outwith its flags, before any work, and refuses a folder, a pipe, a missing folder or one nobody can write in asadopt-out-unwritable, and saysplan written toonce the plan is on disk, so a mistyped--outno longer ends in a traceback after the whole plan is built (#799) #833 for adopt-project.pyplan: an--outin a missing folder, an existing folder, or a folder nobody can write in ends in a traceback after the whole plan is built, so a mistyped--outgets no refusal and no way out #799).adopt-project.pyconflicted in eight hunks, anddocs/cli.mdmerged by itself. Each hunk keeps both sides:Source.open,Source.ofandSource.in_placetake adopt-project.py:_default_branchreadsorigin/HEADand HEAD by short name, so a source cloned withgit clone -b <branch>is refusedgit-failedwith no remedy, and a HEAD on a localorigin/xor on a name ending in U+00A0 adopts another branch's commit #797'sinsteadand hand it on toclone,in_placeand_at;in_placestill takes the top level_top_levelresolved, with noPath.resolveof its own._source_namedand adopt-project.pyplan: an--outin a missing folder, an existing folder, or a folder nobody can write in ends in a traceback after the whole plan is built, so a mistyped--outgets no refusal and no way out #799'sOUT_UNWRITABLEblock and_write_the_planstand side by side.cmd_planasks--outfirst (adopt-project.pyplan: an--outin a missing folder, an existing folder, or a folder nobody can write in ends in a traceback after the whole plan is built, so a mistyped--outgets no refusal and no way out #799), then_source_spec, both before_work_root, and opens the source withSource.of(named, ..., instead=args.tracking_branch).Plan.open_sourcekeepsoverride is not Noneand the emptylocal_pathrefusal, and passesself.read_at(adopt-project.py:_default_branchreadsorigin/HEADand HEAD by short name, so a source cloned withgit clone -b <branch>is refusedgit-failedwith no remedy, and a HEAD on a localorigin/xor on a name ending in U+00A0 adopts another branch's commit #797);_recorded_sourceholds a path the person gave to_top_leveland passesself.read_atat both of itsSource.in_placecalls.Windows
CI is Linux only (#597). The new tests write no file with
write_textthat they read back as bytes, and every path they make is legal on NTFS.os.path.expanduseron Windows turns~nouser\xinto a path beside the user's profile, which is not there, so it issource-unresolvablethere too.Not in this PR
source-readonlythat is a file) goes with the clone-removal hardening issue, besideSource.clone;Source.clone,_leave_the_clonesand_remove_a_treeare untouched.check'sWHAT WILL HAPPEN, against <repository> @line still names a local source by its repository; the issue names onlyplan'ssourceline.url_repository(the pinnedscripts/repo_shape.py) still drops.gitin its case alone and once, so anoriginendingThing.GITnamestestorg/Thing.GIT, as on main.Known limits
--sourcedirectory this user cannot enter (chmod 000), or one under a directory they cannot enter, still ends in aPermissionErrortraceback, exit 1, as on main: fromgit rev-parsein_top_levelhere, from_origin_urlon main, and fromPath.existsin_source_specfor 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:
Bug Fixes:
Enhancements:
Documentation:
Tests: