Skip to content

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

Merged
brettheap merged 5 commits into
mainfrom
fix/652-adopt-source-clone-record
Oct 16, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

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

This PR goes straight to main, and is not stacked: #650 (D2), which it was authored on, has landed as 3dc34b8. The branch merged main at 2fac979 before its first commit, and at 66b230e (with #718's check --local-remote-dir) before the review's fix round.

What

How adopt-project.py reads --source, and what becomes of the clone it makes.

  • A --source is read by its spelling first (_source_spec).

    • Read in place: a path spelled as one (/, ~, ./, ../, a drive or a share).
    • Cloned: an org/repo name, or a github.com url.
    • A name that is ALSO a path here is refused as source-ambiguous, and the refusal names both spellings: ./acme/Atlas reads the directory, and https://github.com/acme/Atlas clones the repository (L1-24).
  • One name, however it is spelled (_github_name). A trailing /, an empty segment and one .git are dropped. https://github.com/<o>/<r>, git@github.com:<o>/<r>.git and github.com/<o>/<r>, with no scheme, all name <o>/<r>.

    • gh is handed <o>/<r>, the clone is source-readonly/<r>, and the plan records repository: <o>/<r> (L1-43).
    • A url on another host is source-unresolvable, by its host. A missing path that is no owner/repo (a/b/c) is source-unresolvable and never reaches gh.
  • The plan records whether plan made its clone: source.cloned: true or false.

    • A clone plan made, gone or no longer whole, is cloned again from GitHub by its recorded name. It is never read through the current directory. "No longer whole" means it is not the top of a repository of its own, or no longer holds the plan's commit.
    • Any other local_path that is gone is plan-source-gone, named by its key, with --source as the way past it (L1-22, L1-26, L1-27). The way out names no recorded org/repo as GitHub's: the plan does not record the host a clone's origin was on.
  • One clone path (Source.clone). It asks, in order:

    1. the work directory (adopt-project.py: a --work-dir that is a file or cannot be made ends plan, check and execute in a traceback, and --help says nothing of --work-dir or of where --org comes from #650);
    2. whether its target already holds something: adopt-work-dir-occupied, by name, where it was source-unclonable with a remedy about gh auth status. execute's way out begins by saying that no leg repository was created and nothing was pushed, and ends by saying that an earlier execute given that --work-dir keeps the clone it made there;
    3. then it prints the NOTE, then it clones.
  • A clone no plan records is removed (_leave_the_clones, in main's finally, L1-23):

    • plan removes its clone when it writes no plan, and keeps it once the plan is written, whatever ends the run after;
    • check removes its --source clone, and the one it makes again;
    • a clone is removed only while its real path is still the one it was made at, so nothing a symlink leads to goes, whenever the symlink was put there;
    • read-only files are removed too, as Windows needs, and no mode is ever lowered;
    • a clone that cannot be removed is named in a NOTE on stderr, and the exit stays the command's.

    execute keeps its clone, and the work directory it made says so on its last line, as before.

  • --help for --source (plan, check and execute) and --work-dir (plan and check) says all this. docs/cli.md is regenerated.

Why (repro on main)

Probed on 2fac979 and on d7b12c5 (#650's head), with a stand-in gh that parses names as gh does and clones from disk. No real gh was run.

  • L1-24. The current directory held ./acme/Atlas, with one local-only commit.
    • plan --source acme/Atlas exited 0, made 0 gh calls, and planned that directory.
    • Its plan's clone gone, check printed "is cloned again, as --source acme/Atlas would clone it", made no call, and read the directory: plan-stale and plan-uncovered: LOCAL.md.
  • L1-43. With gh's own parse, --source acme/Atlas/ was refused source-unclonable: … expected the "[HOST/]OWNER/REPO" format, plus the gh auth status remedy. With a stand-in that skips the parse, it cloned into source-readonly/ itself. Then adopt-not-the-source refused it, and the clone stayed.
    • acme//Atlas, acme/Atlas.git, the urls and github.com/acme/Atlas each went to gh as typed. None wrote a plan.
  • L1-22. A local source with no origin, moved away: check and execute handed the path to gh repo clone and refused source-unclonable with the gh auth status remedy.
    • A recorded D:\work\Atlas was source-unresolvable: --source 'D:\\work\\Atlas' is neither…, naming a --source nobody passed.
  • L1-27. A moved clone whose origin is gitlab.com/acme/Atlas was cloned again from github.com as acme/Atlas. Only plan-stale stopped it.
  • L1-26. A clone plan made was cleaned four ways: emptied, files removed with directories kept (macOS's cleaner), packs removed, HEAD removed. Every one was refused git-failed.
    • Emptied inside another repository, it was read as that repository, until a git ls-remote failed as "a git or gh command failed".
  • L1-23. One run each of plan, check --source, a refused plan, execute --source and check (its plan's clone gone) left 4 clones in the temp folder. Three of them said nothing.
    • check --work-dir W and then execute --work-dir W, as check's help says to run them, failed at the clone check left: source-unclonable with the gh auth status remedy.
  • Two spellings the review asked about (on 2fac979):
    • --source Atlas with no ./Atlas is source-unresolvable before any gh call. It is unchanged.
    • github.com/acme/Atlas cloned, and was then refused adopt-not-the-source, with or without --org, so it never worked. It is now read as acme/Atlas.

How

All of it is in adopt-project.py.

  • _source_spec, _repository_in_url, _github_name and SOURCE_URL_RE read a --source:

    1. a path by its spelling;
    2. a url, or github.com/…, by its host;
    3. an owner/repo name, refused as source-ambiguous when it is also a path here;
    4. anything else as a path.

    A name that is not text, or begins with - (S8705), is never an owner/repo, as before.

  • Source.open takes a --source. Source.in_place reads a path. Source.clone clones a name, and asks the work directory, the target, and only then prints the NOTE.

  • Plan.open_source and _recorded_source replace _where_the_clone_is_gone.

    • A recorded local_path is read as a path and never as a --source.
    • A recorded repository is only ever cloned from GitHub, by its name, and never read as a directory here.
    • Only source.cloned: true lets check and execute clone again.
    • _not_a_whole_clone asks git rev-parse --show-toplevel and cat-file -e <commit>^{commit}.
    • A clone made again at the plan's own local_path (the same --work-dir) is the plan's source again, and is kept.
  • render_plan writes cloned: after local_path:, with three comment lines above source:.

  • _leave_the_clones runs before _leave_the_work_root in main's finally.

    • It removes each clone Source.clone listed on args.clones for plan and check, and keeps plan's once cmd_plan has written the plan (args.plan_written, set right after write_lf), whatever ends the run after.
    • It leaves any clone whose real path is not the work directory's own source-readonly/<repo>: a symlink at any level, there before the run or put there during it, is never followed. Source.clone lists each target with the real path it had when it was made, and only that path is removed, its parent only then.
    • _remove_a_tree retries only an os.unlink, os.remove or os.rmdir that failed, after ADDING the write bit (onexc from Python 3.12), never changes a symlink, and raises anything else. A clone that cannot be removed is named in a NOTE on stderr, inside contextlib.suppress(OSError, UnicodeError), so no traceback leaves main's finally.
  • --help: plan --source; check --source and execute --source (SOURCE_OVERRIDE_HELP); and --work-dir on plan and check (_work_dir_help's new but, and CLONE_TARGET_HELD). Each code sits whole on one line at 80 columns.

The two new codes, source-ambiguous and plan-source-gone, are named in --help, and so in docs/cli.md. AGENTS.md names neither, and no registry lists codes; tests/test_agents_md_codes.py and tests/test_repo_hygiene.py ran in both modes.

Compatibility

Tests

tests/test_adopt_source_clone_record.py is new, with 50 cases. They run in process, with gh repo clone stood in for by a git clone of a repository on disk (gh_clones). The helpers are imported from tests/test_adopt_temp_work_roots.py and tests/test_adopt_plan_leg_path_help.py, not copied.

Changed tests:

  • tests/test_adopt_temp_work_roots.py: gone() follows the NOTE's new ending.
  • tests/test_adopt_work_dir_refusal.py: the re-clone-order test's plan records cloned: true, since a path the person gave is never cloned again; and adopt-work-dir-occupied joins plan's and check's --work-dir codes.
  • tests/test_adopt_template_values.py: six hand-edited local_path cases are now plan-source-gone, by the key, where they named a --source nobody passed. Their guards are kept: no traceback, no gh, exit 2, no leg.

Revert table, on cecc7e0. Each change was reverted alone, in its own scratch copy. Each run covered the new file, the three changed files and tests/test_cli_reference.py: 185 tests, all passing with the change. "Fails" counts the tests that fail with the change reverted; the test names are in the writer's report.

change reverted fails
C1 source-ambiguous (a name that is also a path here is read as the path) 3 (plan, check, execute)
C2 a missing a/b/c goes to gh again 3 (incl. S8705's -u/x and --upload-pack=x/y cases)
C3 no normalisation, no url reading 12 (every spelling, the url control, the recorded testorg/Thing/, another host)
C4 a recorded name read through the current directory 2
C5 cloned: not written 16 (every re-clone test, D2's order test, #526's two)
C6 a person's moved path cloned again from its origin's name 5
C7 a gone path handed to Source.open again 14 (incl. the six template_values cases and the drive letter)
C8 _not_a_whole_clone answers "whole" 6
C9 no occupied-target question 3
C10 nothing removed 8
C11 a re-clone at the plan's path listed for removal 1
C12 no real-path guard at all 2 (the symlink there before the run, and the one put there during it)
C13 no read-only retry 1
F1 the first head's guard (islink of the clone and its parent) 1 (the work directory swapped during the run)
F2 the clone kept only when plan ends 0 1 (the report cut short)
F4a any failed function retried, os.open included 1 (the unreadable directory: a TypeError traceback)
F4b no NOTE 2
F4c the mode set to S_IWRITE rather than the bit added 1 (a directory loses read and search)
N1 execute's old way out 1
F6 plan-source-gone's old sentence 14
N4 the way out naming the recorded org/repo 6
C14 the NOTE's old words 2
C15 the help texts 12 (incl. test_cli_reference's 5)
C16 docs/cli.md not regenerated 5

Targeted runs, on cecc7e0 with dirty 0 (after the merge of main 66b230e). Flags: /usr/bin/python3 -m pytest, a stub gh that refuses everything, GH_TOKEN=bogus, an empty GH_CONFIG_DIR. The files: the 96 from grep -l -E 'adopt-project|adopt_project|\bADOPT\b' tests/*.py, which include test_agents_md_codes.py, test_repo_hygiene.py, test_shape_doctor.py and test_scaffold_leg_path_collisions.py, plus test_cli_reference.py. That is 97 files, in 4 shards per mode.

  • Mode A (git-filter-repo on PATH): 6821 passed, 13 skipped, 1 xfailed, 0 failed.
  • Mode B (PATH=stub:/usr/local/bin:/usr/bin:/bin, filter-repo hidden): 6446 passed, 388 skipped, 1 xfailed, 0 failed.

Windows

  • CI is Linux only (ci: run the test suite on Linux only #597). Every path and name the new tests make is legal on NTFS. A url --source with a scheme, or in git's user@host: form, is only ever a string and never touches the file system. github.com/<o>/<r> with no scheme is also asked whether it is a path here, for source-ambiguous.
  • Read-only files. Windows refuses to unlink a read-only file, and git writes a clone's packs read-only. _remove_a_tree makes such a file writable and retries. The test emulates Windows' refusal on Linux, and it fails with the retry reverted (C13).
  • Paths. A drive-letter local_path is refused by its key on every platform. The symlink test skips where symlinks cannot be made.

Not in this PR

  • A local --source that is a SUBDIRECTORY of a repository plans only that subtree (files: 1 of 3) and records the subdirectory. It goes on Tracking: adopt-project.py, converting an existing repository in place #457's checklist.
  • execute keeps its own clone in a --work-dir it was given. A re-run given that directory is refused adopt-work-dir-occupied, by name, rather than with the gh remedy.

Known limits

Closes #652

🤖 Generated with Claude Code

Brett Heap and others added 3 commits October 16, 2026 12:18
…refused as `adopt-work-dir-unwritable` by `plan`, `check` and `execute`, and `plan` refuses to clone into one it cannot write in, so none of them ends in a traceback before any leg, and `--help` says what `--work-dir` is and where `--org` comes from (#650)

On main at a524542, before any leg existed:
- `plan`, `check` and `execute --work-dir <a file>` ended in a
  FileExistsError traceback, exit 1; under a file, NotADirectoryError;
  under a `chmod 500` directory, PermissionError; and a symlink loop, a
  RuntimeError from Python 3.12's `Path.resolve` (L1-19).
- `plan --source acme/Atlas --work-dir <that directory itself>` ended in
  a PermissionError traceback at `source-readonly` (L1-20).
- With that directory, `check --source nonsense` refused
  `source-unresolvable` while `execute` refused
  `adopt-work-dir-unwritable` (L1-21), and with a plan whose recorded
  clone is gone, `check` printed the "is cloned again" NOTE before its
  refusal while `execute` printed none (L1-28).
- `--work-dir` had no help on the three commands, nor `--org` on `plan`
  (L1-25, L1-49).

Now:
- `_work_root` reads `--work-dir` with `os.path.realpath` and refuses one
  it cannot make as `adopt-work-dir-unwritable`, naming it and the
  system's reason; `execute` says first that no leg repository was
  created and nothing was pushed. `_work_dir_refusal` is the one refusal
  of a work directory, which #524's refusals now use too.
- `Source.open` refuses to clone an `org/repo` source into a work
  directory this user cannot write in, before it makes or says anything;
  `plan` passes it `_unwritable_work_dir`, and a local source is read in
  place as before.
- `check` passes its answer to `Plan.open_source`, which refuses before
  the NOTE, and turns any failure to open the source (`OSError` or
  `Refusal`) into the work-directory refusal when the directory is
  unwritable, as `execute`, asking first, does.
- `--work-dir` on `plan`, `check` and `execute`, and `--org` on `plan`,
  have help (`_work_dir_help`); docs/cli.md is regenerated.

tests/test_adopt_work_dir_refusal.py: 24 tests, each failing with the
change reverted.

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

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n any spelling is cloned by its name, the plan records whether `plan` made its clone (`source.cloned`), and a clone no plan records is removed, so `acme/Atlas` no longer plans a directory of that name, a trailing `/` no longer clones into `source-readonly/` itself, and a recorded clone that is gone or emptied is no longer refused in words about `gh` (#652)

- `_source_spec` reads a `--source` by its spelling first. A name that is
  also a path here is refused as `source-ambiguous`, naming
  `./<org>/<repo>` and the github.com url (L1-24). A missing path that is
  no `owner/repo` never reaches `gh`.
- `_github_name` drops a trailing `/`, an empty segment and one `.git`, and
  a github.com url (or `github.com/<owner>/<repo>`) names its
  `owner/repo`; a url on another host is `source-unresolvable` (L1-43).
- `Source.clone` is the one clone: it asks the work directory (#650), then
  whether its target already holds something (`adopt-work-dir-occupied`,
  where it was `source-unclonable` with a remedy about `gh auth status`),
  then says the NOTE, then clones.
- `plan` records `source.cloned`. `check` and `execute` clone the plan's
  repository again, from GitHub, only where `plan` made the clone and it is
  gone or no longer whole (`_not_a_whole_clone`: not the top of a
  repository of its own, or without the plan's commit). Any other
  `local_path` that is gone, and a plan with no `cloned:`, is
  `plan-source-gone`, naming `--source` (L1-22, L1-26, L1-27).
- `_leave_the_clones`, in `main`'s `finally`, removes the clone `plan` or
  `check` made that no plan records, read-only files included; never one
  through a symlink, never a path the person passed, never `execute`'s
  (L1-23).
- `--help` for `--source` and `--work-dir` says so; docs/cli.md is
  regenerated.
- tests/test_adopt_source_clone_record.py is new (45 cases). Three tests
  change: the NOTE's words, D2's re-clone test (its plan now records
  `cloned: true`) and its help codes, and six hand-edited `local_path`
  cases, now `plan-source-gone` by the key.

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

sourcery-ai Bot commented Oct 16, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR makes --source resolution spelling-driven and GitHub-name aware, persists whether a plan created its clone, safely recreates only damaged or missing plan-created clones, cleans up unrecorded transient clones, and updates tests and CLI documentation for the new behavior.

Sequence diagram for plan-created source recovery

sequenceDiagram
    participant Command
    participant Plan
    participant Source
    participant GitHub
    Command->>Plan: open_source(override, work_root)
    Plan->>Plan: _recorded_source(local_path)
    alt source.cloned is not true
        Plan-->>Command: plan-source-gone
    else clone is present and whole
        Plan->>Source: in_place(local_path)
        Source-->>Command: recorded source
    else clone is gone or not whole
        Plan->>Plan: _recorded_repository()
        Plan->>Source: clone(repository, work_root)
        Source->>GitHub: gh repo clone owner/repo
        GitHub-->>Source: source-readonly/repo
        Source-->>Command: recreated source
    end
Loading

Entity relationship diagram for persisted source provenance

erDiagram
    PLAN {
        string local_path
        string repository
        boolean cloned
        string commit
    }
    SOURCE_CLONE {
        string path
        string repository
        string commit
    }
    PLAN ||--o| SOURCE_CLONE : records
Loading

Flow diagram for --source resolution

flowchart TD
    A[--source spelling] --> B{Path spelling?}
    B -->|Yes| C[Read path in place]
    B -->|No| D{GitHub URL or github.com path?}
    D -->|Yes| E{Valid owner/repo?}
    E -->|No| F[source-unresolvable]
    E -->|Yes| G[Normalize to owner/repo]
    D -->|No| H{Valid owner/repo name?}
    H -->|No| I[Read existing path or refuse source-unresolvable]
    H -->|Yes| J{Also an existing path?}
    J -->|Yes| K[source-ambiguous]
    J -->|No| G
    G --> L[Source.clone]
    C --> M[Source.in_place]
Loading

Flow diagram for transient clone cleanup

flowchart TD
    A[Source.clone] --> B[Record clone in args.clones]
    B --> C{Command result}
    C -->|plan wrote a plan recording clone| D[Keep clone]
    C -->|plan wrote no plan| E[_leave_the_clones]
    C -->|check| E
    E --> F{Symlinked path?}
    F -->|Yes| G[Keep clone]
    F -->|No| H[_remove_a_tree]
    H --> I[Make read-only files writable]
    I --> J[Remove clone]
Loading

File-Level Changes

Change Details Files
Introduces deterministic parsing and normalization of --source values, distinguishing local paths, GitHub repository names, and supported GitHub URL/SCP spellings before filesystem or gh operations.
  • Adds _source_spec and _github_name parsing with path-first precedence and repository-name normalization.
  • Rejects ambiguous path/repository spellings and unsupported hosts or malformed repository names with dedicated refusal codes.
  • Routes all repository clones through a single target and validation path.
adopt-project.py
tests/test_adopt_source_clone_record.py
Records clone provenance in plans and changes how recorded sources are reopened, so only clones created by plan can be automatically recreated.
  • Writes source.cloned and treats missing legacy values as false for compatibility.
  • Always clones recorded repository names from GitHub rather than interpreting them through the current directory.
  • Validates recorded clones as independent repositories containing the planned commit, recloning damaged or missing plan-created clones and refusing other missing paths as plan-source-gone.
  • Preserves the plan's own work-directory clone behavior and reports occupied clone targets explicitly.
adopt-project.py
tests/test_adopt_source_clone_record.py
tests/test_adopt_temp_work_roots.py
tests/test_adopt_template_values.py
tests/test_adopt_work_dir_refusal.py
Adds lifecycle tracking and cleanup for transient source clones made by plan and check.
  • Tracks clone paths created during a run and removes unrecorded clones in main's cleanup path.
  • Keeps clones recorded by a successful plan, execute clones, user-provided paths, symlink-reached targets, and the plan's recreated clone.
  • Adds read-only-file retry handling for clone deletion, including Windows-style failures.
adopt-project.py
tests/test_adopt_source_clone_record.py
Updates CLI guidance and generated documentation to describe source resolution, clone persistence, refusal codes, and work-directory behavior.
  • Expands --source and --work-dir help for plan, check, and execute.
  • Documents source-ambiguous, plan-source-gone, and adopt-work-dir-occupied.
  • Regenerates the corresponding CLI reference.
adopt-project.py
docs/cli.md
tests/test_adopt_source_clone_record.py

Assessment against linked issues

Issue Objective Addressed Explanation
#652 Interpret --source by its spelling rather than accidentally adopting a same-named current-directory path, and normalize GitHub repository spellings so trailing slashes clone into the repository-named directory. ✅
#652 Record whether the plan created the clone and use that record to handle missing or emptied clones appropriately, without treating a user-provided local path as a tool-created clone. ✅
#652 Remove clones that no plan records, including temporary clones created by execute. ❌ The PR removes unrecorded clones made by plan and check, but explicitly excludes execute: it lists no execute clone for cleanup and says execute keeps its clone. The issue specifically includes a fresh clone made by execute among the clones that should be removed.

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

Brett Heap and others added 2 commits October 16, 2026 22:24
Conflicts in adopt-project.py (check's `--source` help beside main's new
`check --local-remote-dir`, #718/#417) and docs/cli.md: both arguments are
kept, in the order --plan, --source, --local-remote-dir, --work-dir, and
docs/cli.md is regenerated with scripts/render-cli-reference.py. check's
`--work-dir` help is reworded so every code sits whole on one 80-column
line in the narrower help column (r652's F3).

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e at, a plan once written keeps its clone, and a clone that cannot be removed is named and keeps every mode, so a work directory swapped for a symlink, a report cut short or a directory nobody can read no longer costs a file outside, the plan's clone or the run's exit (#652)

r652's review of 4ea0b19:
- F1: `Source.clone` lists each target with the real path it has when it
  is made, and `_leave_the_clones` removes it, and rmdirs its parent, only
  while its real path is still that one. A work directory swapped for a
  symlink during the run (r652's S1a) lost a file in the directory the
  symlink led to.
- F2: `cmd_plan` sets `args.plan_written` right after `write_lf`, and the
  clone that plan records is kept whatever ends the run after, where a
  report cut short removed it, an `--out` put in it included.
- F4: the removal's handler retries only `os.unlink`, `os.remove` and
  `os.rmdir`, adds the write bit where it set the mode to `S_IWRITE`, and
  never changes a symlink; a clone that cannot be removed is named in a
  NOTE on stderr, and the exit is the command's (a mode-000 directory
  ended `check` in a TypeError traceback, exit 1).
- F5: `_source_spec`'s url branch is `_repository_in_url`, byte for byte
  (SonarCloud python:S3776).
- F6: `plan-source-gone` says the plan "does not record it as a clone
  `plan` made", which is true of a plan main wrote too.
- N1: `execute`'s `adopt-work-dir-occupied` says first that no leg
  repository was created and nothing was pushed, and that an earlier
  `execute` keeps the clone it made there.
- N2: `_leave_the_work_root`'s docstring names `_leave_the_clones`.
- N4: `plan-source-gone`'s way out names no recorded `org/repo` as
  GitHub's: the plan does not record the host of a clone's `origin`.

Tests: five new cases in tests/test_adopt_source_clone_record.py (the
symlink swap, the cut-short plan, two clones that cannot be removed, and
`execute`'s occupied refusal); the refusal's words in it and in
tests/test_adopt_template_values.py follow F6 and N4.

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

Copy link
Copy Markdown

@brettheap
brettheap marked this pull request as ready for review October 16, 2026 22:54
@sourcery-ai

sourcery-ai Bot commented Oct 16, 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 1 day and 2 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit 60f146a into main Oct 16, 2026
5 checks passed
@brettheap
brettheap deleted the fix/652-adopt-source-clone-record branch October 16, 2026 23:40
brettheap pushed a commit that referenced this pull request Oct 17, 2026
main gained 25 pull requests since de92614: #634, #699, #671, #719,
#701, #717, #700, #702, #722, #720, #725, #721, #728, #705, #727, #718,
#744, #743, #749, #740, #737, #734, #742, #751 and #736. No conflict:
AGENTS.md, adopt-project.py and docs/cli.md merged as text, and
README.md and docs/handbook.html are unchanged by the merge. README.md
is 1473 lines, its cap; AGENTS.md 494, its cap; every handbook marker is
current; `render-cli-reference.py --check` is ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
…es every reason THIS checkout cannot answer, and the notes are current to main bf1f099 with the 25 pull requests since de92614 (#563, #683)

- W683 and RV-f's N4 on #742: README's exit-3 list (Keeping a project's
  shape current, :1208-1220) and its handbook twin (`doctor`) named only a
  pin whose commit this checkout does not carry. They now say, as
  shape-doctor.py's EXIT CODES does: a pin's commit it lacks, a partial
  clone missing what is compared (#683), a read that fails
  (`update-upstream-unreadable`), a crashed validator of its own (#278,
  #410), or its own naming policy not loading (#678). +1 line, with
  tests/readme-cap.d/683.txt; README.md is 1474, its cap (1422 + 2 + 5 +
  12 + 22 + 10 + 1). The two `Keeping a project's shape current` markers
  move: `doctor` text and marker, `current` marker only.
- The notes, "What changed since v1.0.1", one entry per pull request from
  `git log --first-parent de92614..bf1f099`, each from its squash commit and
  the issues GitHub says it closes:
  - adopt-project.py: #699, #719, #749, #727, #717, #728 (with #621's
    hint), #743 + #720 + #718, #722, #721;
  - update-shape.py, bump-leg.py and a project's own scripts: #702 (with
    the pinned validators), #634;
  - scaffold, setup, family and the shared readers: #725, #671, #744;
  - shape-doctor.py: #742, #701, #737, #751, #736, #734, #700 + #705,
    #740;
  - install, CI and the docs: the exit-3 sentence above.
- Counts (99 pull requests since bfed143; 109 of 135 milestone issues
  closed at 10:28Z), the "current to main" sha and the open list (26) are
  at main bf1f099, still placeholders for tag time.

Left for round B: lane 5's eight lines (#630 issuecomment-6227129879),
whose pull requests #726, #747, #682, #724, #746, #745 and #738 are open
drafts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
…d A3 (three SHOULDs, four nits), and notes entries for the seven pull requests landed since bf1f099, current to main 2000488 (#563, #683)

Round A4, from lane 2's review (#630 issuecomment-6233033833, READY):
- SHOULD 1: the doctor's exit-3 list names "a validator of its own that
  crashed or could not be run", so the case of one that cannot be given
  its `PYTHONPATH` (shape-doctor.py's EXIT CODES, #278) is listed too:
  README.md:1208-1222, its handbook twin (`doctor`) and the notes' docs
  line. The paragraph re-wraps to one more line:
  tests/readme-cap.d/683.txt is +2, and its text now says which causes
  the EXIT CODES block names and that #742 added the partial clone and
  #701 the naming policy. README.md is 1475 lines, its cap (1422 + 2 + 5
  + 12 + 22 + 10 + 2). Both `Keeping a project's shape current` markers
  move to c728c970: `doctor` text and marker, `current` marker only.
- SHOULD 2: #727's entry names the leg description, whose em dash stopped
  every `execute` against GitHub at the first leg.
- SHOULD 3: #749's entry names `source-ambiguous`, for an `org/repo` that
  is also a directory here.
- Nits: the doctor section's three near-duplicate pairs are merged (#636
  with #737, #632 with #700 and #705, #586 with #736); #718 has its own
  bullet; #634's entry names `git_out`'s spelling for every caller and
  #744's the gate's `--link-source-label` and `--link-remedy`; the header
  comment says 2026-10-17 at main 2000488.

Wave B1, `git log --first-parent bf1f099..2000488`: #706 (#664), #703
(#696), #670 (#646), #682 (#648), #726 (#667), #730 (#666) and #724
(#665), each from its squash commit; #726's and #682's from lane 5's lines
(#630 issuecomment-6227129879), #682's with the unmerged `.gitmodules`
its last round added, and #724's with lane 5's note that v1.0.0's
test-hygiene line on the generator's `.pyc` files closes. The counts (106
pull requests; 116 of 135 milestone issues closed at 11:53Z), "current to
main" and the open list (19) are at main 2000488, still placeholders.

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

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

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

Now:

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

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

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

Closes #796

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