Skip to content

adopt-project.py: the clone plan and check make is claimed with the 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

Merged
brettheap merged 6 commits into
mainfrom
fix/803-adopt-clone-claimed
Oct 18, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

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

A v1.1.1 PR, authored on main at 24a977c0, with main fc289dd7 merged in since (see "Merge of main"). It lands after the v1.1.0 tag, in lane 3's order, refreshed by gh pr update-branch then.

What

plan and check make a clone in the work directory and, since #749, remove it when no plan records it. That clone is now claimed by the run that makes it, and removed only while it is still the directory that run made.

  • A work directory that another user can rename in, or rename from above, is refused before anything is made in it.
    • _work_root asks each directory above the real --work-dir, from / down, BEFORE it makes any missing one, and again after; 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 under a new code, adopt-work-dir-shared, naming it.
    • This is the rule OpenSSH's StrictModes holds authorized_keys to, with two differences: the sticky bit counts, which keeps /tmp open, and group write is not asked. Root is trusted, as StrictModes trusts it.
    • The real path is the only path the run uses from then on. The spelling given is read once, by os.path.realpath, so a symlink in a shared directory that leads into a safe chain is used, as its target.
    • Asked with os.lstat, and never on Windows. The default work directory (tempfile.mkdtemp) is not asked.
  • source-readonly/ is asked before the NOTE and before gh (_source_readonly_refusal):
    • a symlink or a file there is adopt-work-dir-occupied, naming source-readonly (L1-65);
    • one owned by a user other than this one or root, or that every user can write in without the sticky bit, is adopt-work-dir-shared;
    • one this user cannot make a directory in is adopt-work-dir-unwritable.
  • The clone's directory is claimed before gh runs (_claimed). It is made with an exclusive os.mkdir, and git clone clones into it. Any target already there is refused as adopt-work-dir-occupied, an empty one included (L1-98). A mkdir of source-readonly that fails is refused by name, where it was a traceback (L1-65).
  • Every directory a run makes for its work directory, source-readonly/ and the clone is 0755 whatever the umask (_made_0755); the umask can still narrow it, to 0700 under umask 077. The legs, the assembly and --local-remote-dir's parent are still made with the default mode. 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's refusals of a --work-dir that cannot be made are unchanged.
  • The removal is held to what this run made (_remove_a_tree). Right after its mkdir, the run records the identity (st_dev, st_ino) of the clone's directory and of source-readonly/. It removes only while both still match, and never through a symlink.
    • From Python 3.11, on Linux and macOS, it removes relative to a descriptor of source-readonly/, opened without following a symlink (shutil.rmtree(dir_fd=)).
    • On 3.9, 3.10 and Windows it removes by path, after the same checks.
  • A skipped removal is named. Every clone the run made and does not remove gets a NOTE, as a failed removal already did (L1-99 (a)). Paths are compared with os.path.normcase on both sides (L1-102).
  • The plan's own record is kept. A --source clone that check makes at the plan's own source.local_path is no longer listed for removal (L1-97).
  • execute now removes something when its clone does not finish. It still lists no clone, and still keeps a clone it made. Before this PR, a failed gh repo clone left no target behind, because git removes a directory it created itself. Now the run makes the directory first, and git keeps a directory that existed before the clone. Left there, it would make the next execute given the same --work-dir refuse at an empty directory. So however the clone ends short of a clone (gh failing, Ctrl-C, no gh on PATH), execute removes exactly the directory its own mkdir made, and source-readonly/ where it made that too, after the same identity checks, and then raises what ended it.
  • A kill or a power loss can still strand an empty claim, so the empty-directory refusal also says to delete it where no run is using it.

Two lines outside this PR's own code change by one name: _over and _index_entries now spell "a file" as the new constant A_FILE, so the literal is written once (SonarCloud S1192).

Each line of the issue: the code and the words

  • L1-99 (b): source-readonly is swapped for a symlink to victim/, or for victim/ itself, at the start of _remove_a_tree (t111c's injection). check exits 0, victim/Thing and its file stay, and stderr says:
    NOTE the clone this run made, `<W>/source-readonly/Thing`, is not removed: what that path leads to now is not the directory this run made, so nothing is removed through it.
  • L1-99 (a):
    • A source-readonly swapped for a symlink to bigdisk/ during the run gets:
      NOTE the clone this run made, `<W>/source-readonly/Thing`, is not removed: that path now leads to `<bigdisk>/Thing`, so nothing is removed through it.
    • A symlinked source-readonly already there before the run is refused, with no gh call:
      REFUSED adopt-work-dir-occupied: --work-dir `<W>` already holds `source-readonly`, a symlink to `<bigdisk>`, where this run clones testorg/Thing into a directory of its own
      Remediation: Pass a --work-dir that does not hold it, or none: without one, each run makes a new one.
  • L1-98: run B finds run A's claimed directory:
    REFUSED adopt-work-dir-occupied: --work-dir `<W>` already holds `source-readonly/Thing`, an empty directory, where this run clones testorg/Thing, and a run clones only into a directory it has just made, so that it never takes another run's for its own
    Remediation: Pass a --work-dir that does not hold it, or none: without one, each run makes a new one. Or, where no run is using `source-readonly/Thing`, delete it: a run that was killed can leave it empty.
    Run A's clone then fails (source-unclonable), and A removes only what its own mkdir made.
  • L1-97: no refusal. check --source testorg/Thing --work-dir W exits 0 and keeps W/source-readonly/Thing at the plan's record.
  • L1-102: no new words. The recorded path and the real path are compared with os.path.normcase on both sides, so on Windows a Source-Readonly on disk no longer skips the removal in silence.
  • L1-65:
    • A file at source-readonly gets, in plan, check and execute:
      REFUSED adopt-work-dir-occupied: --work-dir `<W>` already holds `source-readonly`, a file, where this run clones testorg/Thing into a directory of its own
      Remediation: Pass a --work-dir that does not hold it, or none: without one, each run makes a new one.
      execute's remedy begins no leg repository was created and nothing was pushed.
    • A mkdir that the system refuses gets:
      REFUSED adopt-work-dir-unwritable: --work-dir `<W>` cannot hold `source-readonly`, where this run clones testorg/Thing: making it failed (Permission denied)
  • Direction 1:
    • A directory above the work directory that every user can write in, without the sticky bit, gets:
      REFUSED adopt-work-dir-shared: --work-dir `<W>` is under `<A>`, which can be written in by every user, without the sticky bit, so another user could rename `<W>`, or a directory between them, and turn this run's removal onto another directory
      Remediation: Pass a --work-dir this user owns, that no other user can write in, and that has no directory above it another user can rename in, or none: without one, each run makes a new one.
      Where <A> is this user's, its mode is the reason, and the way out goes on:
      Or, since `<A>` is this user's, set its sticky bit (`chmod +t <A>`): each entry in it then stays its owner's.
    • One owned by another user reads which is owned by `<user>`, not by this user or root, so ....
    • The work directory itself reads --work-dir `<W>` can be written in by every user, without the sticky bit, so another user could rename or replace what this run makes in it, with the same remedy.
    • A shared source-readonly reads holds `source-readonly`, which can be written in by every user, without the sticky bit, so another user could rename or replace the clone this run makes in it, with the remedy Pass a --work-dir that does not hold it, or none: ....
    • A directory root owns at 0755 that this user cannot write in, as an earlier sudo run leaves one, gets main's true refusal again: adopt-work-dir-unwritable.
    • A symlink loop or a file above the work directory keeps 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's refusal, adopt-work-dir-unwritable.

Why (repro on main)

Every new test fails on main but the controls and the six that hold #650's own words for a loop or a file above the work directory (24a977c0; adopt-project.py's clone code is #749's, unchanged since bf1f099b):

How

  • adopt-project.py:
  • The --work-dir help of all three commands names adopt-work-dir-shared for the work directory, each directory above it, and its source-readonly. plan and check say that a source-readonly that is a file or a symlink, or that holds the clone's directory already, even empty, is adopt-work-dir-occupied. docs/cli.md is rendered again.

The residual window

Except through group write, and on Windows (both below), no other user can now rename the work directory or anything above it, on any Python. Every directory above the work directory is asked, and so are the work directory and its source-readonly/. That covers what #843's review measured on the first head, which this PR now refuses:

  • a work directory swapped, from a directory above it, between the claim's mkdir and the identity read right after it, which the claim then recorded as its own: a second process won 5 of 20 runs on Python 3.12 under load, and the swap made in process won on every Python;
  • git's own cleanup of a failed clone, which removes by path: a swap at the creation of .git lost the victim's files in 17 of 20 runs on the first head, and at the clone's directory in 8 of 20 on main;
  • the window of the removal by path on Python 3.9 and 3.10.

Where the removal is by descriptor, a rename of source-readonly/, or of a directory above it, after the checks does not redirect it. That is Python 3.11 and later, on Linux and macOS. The removal is relative to a descriptor of the source-readonly/ this run made, opened without following a symlink and held to its recorded identity. A rename of the clone's own directory inside source-readonly/ after its check still redirects it, on every Python; only a user who can write in source-readonly/ can make it, and group write is not asked.

On Python 3.9 and 3.10, and on Windows, the removal is by path, after the same identity checks. A rename of source-readonly, or of a directory above it, between the last check and shutil.rmtree's own open of the path still removes the directory of that name wherever the new path leads. Measured on Python 3.9.23, with the swap made inside the run's own process. On Windows, where shutil.rmtree itself walks by path, the window lasts the whole removal: a rename during the walk turns the rest of it onto whatever the path then leads to.

What stays open, said plainly:

  • a user who can write, through its group, in source-readonly/, the work directory or a directory above it: group write is not asked, on any Python;
  • Windows, where nothing is asked and shutil.rmtree walks by path: any user whose ACL lets them rename there can open the window;
  • root;
  • this user's own other processes.

Merge of main

Main fc289dd7 is merged into the branch as a merge commit, not a rebase: the head is now b1a40a64, whose parents are 08f7a283 and fc289dd7. Since 24a977c0, main has gained nine commits in adopt-project.py: #817 (#708), #630 (#563), #829 (#801), #842 (#800), #830 (#797), #833 (#799), #825 (#716), #844 (#807) and #831 (#796).

Tests

  • tests/test_adopt_clone_claimed.py is new, with 42 cases:
    • L1-99 (b): t111c's swap, as a symlink and as a directory;
    • the swap after the checks: it skips on the platform (Python before 3.11, Windows, or an rmtree that does not walk by descriptor), and otherwise asserts FD_REMOVAL first;
    • the L1-99 (a) NOTE;
    • L1-98: the forced race, with a stub gh that waits on a file in steps of 0.1 s, and an empty directory already there;
    • execute whose clone fails, and whose clone is cut short by Ctrl-C or by no gh on PATH: the claim is gone, the exception is raised, and the same command again reaches gh;
    • L1-97;
    • L1-102, with os.path patched;
    • L1-65, in all three commands, and the mkdir backstop, both as a file made since and as a refused mkdir;
    • an unwritable source-readonly refused before the NOTE;
    • direction 1:
    • controls, which pass on main too: a sticky (1777) directory above, work directory and source-readonly; a root-owned directory above; a --work-dir given through a symlink in a 0777 directory whose target is in a safe chain; root's 1777 work directory (/tmp, emulated); root's 0755 work directory this user cannot write in, which gets adopt-work-dir-unwritable;
    • the 0755 mode of source-readonly and the claim under umask 0, and a missing, deep --work-dir made by a plan under umask 000: each directory 0755, and used.
  • Ownership is arranged for real where the test runs as root (chown), and emulated through os.lstat otherwise. Mode tests skip where a mode does not stop this user, and on Windows.
  • Two of 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 tests change:
    • test_a_clone_reached_through_a_symlink_is_never_removed pinned the old behaviour, L1-99 (a)'s: the clone was made through a symlinked source-readonly and kept, exit 0. It now expects the refusal, no gh call, and nothing made through the link.
    • test_a_clone_is_removed_where_a_read_only_file_cannot_be_unlinked gains one line: it emulates Windows, so it now also takes the removal by path, as Windows has no removal by descriptor. On Linux the removal by descriptor takes no handler. POSIX unlinks a read-only file, and a handler there would act through a path again.
  • tests/test_adopt_work_dir_refusal.py's code table names the new code, so its help tests ask for it in each command, whole on one line.

Revert table. Each change is reverted alone on 08f7a283, and these runs of test_adopt_clone_claimed.py, test_adopt_source_clone_record.py and test_adopt_work_dir_refusal.py (121 cases) fail:

Row Reverted alone Fails
R0 all of it (main's adopt-project.py) 38: 31 of the 42 new cases (all but the five controls and the six that hold #650's words), #749's symlink test, and 6 help cases
Rcf both fix rounds (the first head's adopt-project.py, cf36411e) 17: the cases the rounds add or change for findings 1, 2, 3 and 5 and the sticky bit
Rbb fix round 2 (round 1's adopt-project.py, bba7e445) 10: the six cases of a loop or a file above, and the four that ask for the sticky-bit way out
R1 the work directory's own shared check 4: the 0777 work directory in plan, check and execute; a work directory another user owns
R2 source-readonly that is a symlink or a file 5: the file in all three commands, the file made since, and #749's symlink test
R3 source-readonly another user can rename in 2: a 0777 one, and one another user owns
R4 the check that this user can write in source-readonly 1: refused before the NOTE
R5 the exclusive claim (makedirs(exist_ok=True), and an empty target taken) 2: the race, and the empty directory
R6 the identity checks 1: t111c's swap with a directory
R7 the removal by descriptor (by path everywhere) 1: the swap after the checks
R7b FD_REMOVAL forced false 1: the swap after the checks, at its assertion of FD_REMOVAL
R8 the NOTE for a path that now leads elsewhere 1: the L1-99 (a) NOTE
R9 the NOTE for a directory that is not the run's 2: t111c's swap, as a symlink and as a directory
R10 normcase 1: L1-102
R11 the plan's own record kept 1: L1-97
R12 execute's removal of its claim 3: a failed clone, Ctrl-C, and no gh
R13 the mkdir backstop 2: a file made since, and a refused mkdir
R14 mode 0755 of source-readonly and the claim 1: the mode under umask 0
R15 the sticky-bit exemption 87: every case with a --work-dir, since the suite's own temporary directory is below /workspace/projects, which is at 1777; the sticky-bit control among them
R16 the owner question 3: source-readonly, the work directory and a directory above, each another user's
R17 the help sentence 10: 6 help cases, and 4 of test_cli_reference.py (run with it)
R18 the empty-directory words 2: the race, and the empty directory
F1 the directories above the work directory 6: the 0777 directory above in all three commands, the sticky bit it names, one another user owns, and the swap from above
F2 execute's removal on any end, not only CommandFailed 2: Ctrl-C, and no gh
F2b the empty-directory refusal's "delete it" 1: the empty directory
F3 root trusted 87: every case with a --work-dir, since / is root's; the root-owned controls among them
N1 the walk stopping at an entry that is no directory 6: the loop and the 0666 file above, in all three commands
S1 the sticky-bit way out 4: the 0777 directory above in all three commands, and the sticky bit it names
F5 _made_0755 (Path.mkdir(parents=True) and os.makedirs again) 1: the deep work directory under umask 000
R19 the work directory made above source-readonly (cf36411e) 2: test_adopt_history_gitlinks.py::test_an_org_repo_source_is_read_as_the_extraction_clones_it, which calls Source.open with a work directory that does not exist (both modes, on 27ba79b9)

N1, S1 and R15 again, at the merged head b1a40a64. Each was reverted alone, in its own clone, and the same three files were run with git-filter-repo on PATH. With main's #796 tests they now hold 175 cases, and unchanged they pass all 175.

  • N1 fails 6: the loop and the 0666 file above, in all three commands, as before.
  • S1 fails 4: the 0777 directory above in all three commands, and the sticky bit it names, as before.
  • R15 fails 116 of 175, where it failed 87 of 121: all 42 of test_adopt_clone_claimed.py, 59 of test_adopt_source_clone_record.py and 15 of test_adopt_work_dir_refusal.py. As before, the suite's temporary directories are below /workspace/projects (1777), so without the sticky-bit exemption the work directories there are refused.

Both modes, on b1a40a64 (main fc289dd7 merged), dirty 0, with /usr/bin/python3 3.12.3 and a stub gh. The run covers 106 files, each run on its own, in 6 shards per mode:

  • the 104 test files that grep -l -E 'adopt-project|adopt_project|\bADOPT\b' tests/*.py names, test_shape_doctor.py and test_scaffold_leg_path_collisions.py among them (it also names conftest.py, which is not a test file);
  • plus test_cli_reference.py and test_no_auto_maintenance.py.

The results:

  • with git-filter-repo on PATH: 7,524 passed, 17 skipped;
  • with it hidden (PATH <stub>:/usr/local/bin:/usr/bin:/bin, where command -v git-filter-repo finds nothing): 7,124 passed, 417 skipped.

The xfailed case of earlier runs, test_shape_doctor.py::test_make_siblings_dry_run_gives_the_rows_word_for_each_slot[a-file], now passes in both modes: main's #772 (#735) made make siblings answer NOT A CLONE, and dropped the xfail.

The suite's temporary directories were under the lane's own scratch directory, below /workspace/projects, which now has the sticky bit (1777). Before that it was 0777, and this PR refused every --work-dir under it, the tests' own included.

Cross-version probe of the first head (cf36411e; Python 3.9.23, 3.11.13 and 3.12.3, in process, with a stand-in gh):

  • t111c's swap, as a symlink and as a directory, removes nothing of the victim on all three.
  • The swap of source-readonly as shutil.rmtree is called is closed on 3.11 and 3.12, and removes the victim on 3.9.
  • On all three: a file at source-readonly is refused, a 0777 work directory is refused with nothing made, and execute whose clone fails leaves nothing behind, twice over.

Windows

Not in this PR

Known limits

  • The residual window, as above.
  • WSL: a --work-dir on a Windows drive (drvfs) reads as mode 0777 and is refused as adopt-work-dir-shared. That is true there: every Linux user of the distribution acts as the same Windows user on that drive.
  • The default work directory is not asked. tempfile.mkdtemp makes it 0700 in the system temp folder, and only a TMPDIR that every user can write in without the sticky bit would open it.
  • --work-dir /tmp and directories root owns. Root is trusted. So --work-dir /tmp (root's, 1777) is used, and stays safe: another user's source-readonly there is still refused. A directory root owns that this user cannot write in, as an earlier sudo run leaves one, gets main's adopt-work-dir-unwritable.
  • A --work-dir under a directory another non-root user owns, or under one every user can write in without the sticky bit, is now refused, though main ran there. That includes a shared /workspace or project folder at 0777. Where such a directory is this user's, setting its sticky bit (chmod +t) keeps the protection and changes nothing for this user; the refusal says so.
  • Drives mounted without ownership read as 0777, and are refused as adopt-work-dir-shared: on macOS an external exFAT, FAT or NTFS drive, or one with "Ignore ownership on this volume"; on Linux, ntfs-3g by default. The way out is a --work-dir on a disk that keeps owners, such as the home directory, or no --work-dir.
  • Running as root with a directory another user owns is refused, as docker run as root with a bind-mounted host directory owned by uid 1000 does: is owned by user id 1000, not by this user or root. The way out is to run as that directory's owner (docker run --user 1000:1000), to give a --work-dir root owns, or to give none.

Closes #803

🤖 Generated with Claude Code

Summary by Sourcery

Harden clone work-directory ownership and cleanup so concurrent or renamed paths cannot redirect removal onto another directory.

Bug Fixes:

  • Prevent clone cleanup from removing directories that were created or replaced by another process during a run.
  • Refuse unsafe or conflicting work directories before cloning, including shared paths, symlinks, files, unwritable locations, and existing clone targets.
  • Clean up claims left by interrupted or failed execute clones while preserving unrelated files and recorded source clones.

Enhancements:

  • Claim clone directories exclusively and verify their identities before removal, using descriptor-based cleanup where supported.
  • Create run-owned work directories with controlled permissions and provide explicit diagnostics when cleanup is skipped or a work directory is unsafe.

Documentation:

  • Update CLI documentation and help text with the new work-directory safety rules and refusal conditions.

Tests:

  • Add comprehensive coverage for clone races, path swaps, ownership and permission checks, interrupted clones, platform-specific cleanup, and work-directory handling.

Chores:

  • Consolidate repeated file descriptions through a shared constant.

Brett Heap and others added 2 commits October 17, 2026 15:39
…his run's own `mkdir` and removed only while it is still that directory, and a `--work-dir` or `source-readonly/` another user can rename in is refused first, so a swapped `source-readonly` no longer turns the removal onto another directory, two runs on one `--work-dir` no longer remove each other's clone, and a skipped removal is named (#803)

On main, #749's removal asked the clone's real path and then removed the
path, in two steps. `source-readonly`, swapped for a symlink between them,
removed the directory of that name wherever the link led (#457's L1-99 (b)).
A skipped removal said nothing (L1-99 (a)). Two runs given one `--work-dir`
could both list one target, and the run whose clone failed removed the
other's (L1-98). `check --source` removed a clone it had made at the plan's
own `source.local_path` (L1-97). The paths were compared without
`os.path.normcase` (L1-102). A file at `source-readonly` was a traceback
(L1-65).

Now:
- `_work_root` refuses a `--work-dir` that another user owns, or that every
  user can write in without the sticky bit, before anything is made in it,
  as the new `adopt-work-dir-shared`. It asks with `os.lstat`, and never on
  Windows.
- `Source.clone` asks `source-readonly/` before the NOTE. A symlink or a file
  is `adopt-work-dir-occupied`, one another user can rename in is
  `adopt-work-dir-shared`, and one this user cannot write in is
  `adopt-work-dir-unwritable`. A failed `mkdir` there is refused by name.
- The clone's directory is claimed with an exclusive `mkdir` (0755) before
  `gh` runs, and any target already there, an empty one included, is
  refused. Only that claim is listed, with the identity (`st_dev`,
  `st_ino`) of the directory and of `source-readonly/`.
- `_remove_a_tree` removes only while both identities still hold, never
  through a symlink. From Python 3.11 on Linux and macOS it removes relative
  to a descriptor of `source-readonly/` (`shutil.rmtree(dir_fd=)`). On 3.9,
  3.10 and Windows it removes by path after the same checks.
- Every skipped removal is named in a NOTE, and paths are compared with
  `os.path.normcase` on both sides.
- `check --source` keeps the clone it made at the plan's own record.
- `execute`, whose clone fails, removes the directory its own `mkdir` made,
  after the same checks, so a re-run is not refused at an empty directory.

Tests: tests/test_adopt_clone_claimed.py is new, with 22 cases. Two of
#749's tests change: the symlinked `source-readonly` is now refused before
the clone, and the read-only unlink emulation also emulates Windows's
removal by path. The help's code table names the new code, and
docs/cli.md is rendered again.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng above `source-readonly`, as #749 did, so a caller of `Source.open` that names a new one is no longer refused `adopt-work-dir-unwritable` (#803)

The first commit made `source-readonly` with an exclusive `os.mkdir`, which
needs the work directory to exist. `_work_root` makes it for every command,
but tests/test_adopt_history_gitlinks.py calls `Source.open` directly with a
work directory that does not exist, and both of its `org/repo` cases were
refused. Only `source-readonly` itself and the clone's directory are made
exclusively; the directories above are made as on main.

The new test of a `source-readonly` this user cannot write in, which is
refused before the NOTE, is added: the revert of that check failed nothing
before.

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

Hardens clone creation and cleanup against concurrent runs and directory-swapping attacks by validating work-directory ownership, exclusively claiming clone paths, recording filesystem identities, and using descriptor-relative removal where available, with updated CLI documentation and extensive regression tests.

Sequence diagram for claimed clone creation and safe cleanup

sequenceDiagram
    participant Command
    participant Source
    participant WorkDir
    participant FS
    participant Git
    participant Cleanup

    Command->>WorkDir: _work_root()
    WorkDir->>FS: os.lstat(real_work_dir)
    alt shared or foreign-owned
        FS-->>WorkDir: shared directory
        WorkDir-->>Command: Refusal(adopt-work-dir-shared)
    else accepted
        Command->>Source: clone(repository)
        Source->>FS: _source_readonly_refusal()
        alt occupied or shared or unwritable
            FS-->>Source: refusal
            Source-->>Command: Refusal
        else available
            Source->>FS: _claimed()
            FS->>FS: os.mkdir(source_readonly, 0755)
            FS->>FS: os.mkdir(clone_target, 0755)
            FS-->>Source: _Claim identities
            Source->>Git: run gh repo clone
            alt clone fails
                Source->>Cleanup: _remove_a_tree(_Claim)
                Cleanup-->>Source: remove only matching directories
                Source-->>Command: Refusal(source-unclonable)
            else clone succeeds
                Source-->>Command: claimed clone
            end
        end
    end
Loading

Flow diagram for identity-checked clone removal

flowchart TD
    A[Clone cleanup starts] --> B{Path still matches recorded spelling?}
    B -- No --> N[Emit NOTE and keep clone]
    B -- Yes --> C{Removal by descriptor available?}
    C -- Yes --> D[Open source-readonly without following symlinks]
    C -- No --> E[Inspect parent and clone with lstat]
    D --> F{Parent and clone identities match?}
    E --> F
    F -- No --> N
    F -- Yes --> G[Remove only the claimed clone]
    G --> H{Run made source-readonly?}
    H -- Yes --> I[Remove parent only if identity still matches]
    H -- No --> J[Cleanup complete]
    I --> J
Loading

File-Level Changes

Change Details Files
Introduces exclusive clone-directory ownership and validates shared or unsafe work-directory state before cloning.
  • Rejects work directories owned by another user or world-writable without sticky-bit protection on POSIX.
  • Rejects symlinked, file-backed, shared, or unwritable source-readonly paths with explicit refusal codes.
  • Creates source-readonly and clone targets with exclusive mkdir calls and fixed 0755 permissions.
  • Refuses pre-existing clone targets, including empty directories, to prevent one run from claiming another run’s directory.
adopt-project.py
docs/cli.md
tests/test_adopt_clone_claimed.py
tests/test_adopt_source_clone_record.py
tests/test_adopt_work_dir_refusal.py
Tracks clone and parent identities throughout cleanup so removal cannot be redirected to a different directory.
  • Adds _Claim metadata containing real paths, device/inode identities, and parent-creation state.
  • Uses descriptor-relative shutil.rmtree on supported Python/POSIX combinations and identity-checked path removal elsewhere.
  • Skips removal when paths or identities no longer match and emits a NOTE describing why the clone was kept.
  • Normalizes path-case comparisons for platform-consistent cleanup decisions.
adopt-project.py
tests/test_adopt_clone_claimed.py
tests/test_adopt_source_clone_record.py
Aligns clone lifecycle behavior with ownership tracking across plan, check, and execute.
  • Removes failed execute clones only when the current run created their directories.
  • Preserves a clone made at the plan’s recorded source path instead of scheduling it for removal.
  • Updates occupied-target messaging and command help to describe empty-directory claims and the new shared-work-dir refusal.
  • Adds regression coverage for swaps, races, failed clones, refusal ordering, permissions, ownership, sticky bits, and cross-platform behavior.
adopt-project.py
docs/cli.md
tests/test_adopt_clone_claimed.py
tests/test_adopt_source_clone_record.py
tests/test_adopt_work_dir_refusal.py

Assessment against linked issues

Issue Objective Addressed Explanation
#803 Claim each clone target exclusively for the run that creates it, and remove it only while its recorded directory identities still match; never remove another run’s clone or a directory reached through a swapped symlink. ✅
#803 Refuse unsafe or unusable work directories and source-readonly paths before cloning, with a named refusal instead of a traceback; report skipped removals and compare paths using platform-appropriate case normalization. ✅
#803 Keep a --source clone when it is at the plan’s recorded path, so cleanup does not remove the plan’s own source. ✅

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-17T20:09:23Z) Lane 1's independent review of #843 at cf36411: CHANGES NEEDED

Reviewer r803, read-only. A local-only merge of cf36411e into main fc867207 is clean: one auto-merge of adopt-project.py, giving 81502403. Probes ran with a stand-in gh that clones only testorg/Thing from disk and otherwise prints to stderr and exits 97, GH_TOKEN=bogus-r803, an empty GH_CONFIG_DIR, GIT_CONFIG_GLOBAL=/dev/null and GIT_ALLOW_PROTOCOL=file. Every victim directory was under my scratch. Pythons used: 3.9.23, 3.10.18, 3.11.13 and 3.12.3. <S> stands for a scratch directory.

What holds

  • The issue's lines are all fixed on the merge, and each still reproduces on main:
    • L1-99 (b), t111c's swap: main removes victim/Thing (as a symlink and as a directory). The merge keeps it and prints the NOTE, on all four Pythons.
    • L1-99 (a): main exits 0 with empty stderr and the clone lands in bigdisk/. The merge exits 2 with adopt-work-dir-occupied, and bigdisk/ stays empty.
    • L1-65: main ends in FileExistsError: [Errno 17] File exists: '<S>/source-readonly' (exit 1). The merge refuses by name.
    • L1-98, L1-97 and L1-102: their tests fail with main's file (R0) and pass on the merge.
  • Order: 18 runs, six cases in each of plan, check and execute. The cases were a symlinked, file, 0777 or 0555 source-readonly, a 0777 work directory, and an empty target. Every run was refused by name, with 0 gh calls, no NOTE, no cloning line, and nothing left beyond what was there before. With the recorded clone gone and source-readonly a symlink, check and execute refuse before the plan's NOTE.
  • Ordinary inputs are byte-identical on main and the merge, after only the scratch prefix is normalized: plan from GitHub and from a local path, check, and check --source. That covers stdout, stderr, exit codes and both plan files.
  • A relative --work-dir, one with .., and one through a symlink each remove only the run's own clone.
  • A bind mount over the clone's directory, made after the checks: main removes the victim; the merge keeps it.
  • No new network call. Sonar duplication on new code is 0.0%.
  • 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 two changed tests are honest. The symlink test pinned the old L1-99 (a) behaviour. FD_REMOVAL=False is what Windows runs.

Findings, ranked

1. The residual-window section is false. On every Python, 3.11 and later included, a user who can rename in a directory above --work-dir can still turn a removal onto another directory of this user's.

  • In-process repro. Wrap os.mkdir so that, right after _claimed makes W/source-readonly/Thing, it runs os.rename(W, W+'.real'); os.symlink(victimroot, W). Here victimroot holds source-readonly/Thing/precious.txt. Then run each command with --source testorg/Thing --work-dir W.
    • Result, the same on 3.9.23, 3.10.18, 3.11.13 and 3.12.3, in plan, check and execute:
      3.12.3 FD_REMOVAL True execute exit 2 swap fired True
      victim precious.txt exists: False | victim Thing exists: False
      
    • Cause: _claimed reads realpath and lstat after its mkdir, so it records the victim as the claim. The descriptor removal is then held to the victim's own identity.
  • A real second process wins it. Setup:
    • shared/ at 0777, which is the case the body names;
    • W at 0755, owned by this user;
    • a racer that waits for inotify IN_CREATE of Thing and then calls renameat2(W, Wsym→victimroot, RENAME_EXCHANGE).
    • Results:
      • Python 3.12.3, plan, 20 runs under CPU load: attacker won 5 of 20 (0 of 20 without the extra load).
      • In a won run, stderr shows only git's destination path ... already exists and is not an empty directory and no NOTE, so the remover is _remove_a_tree.
    • git's own cleanup is a second remover:
      • With the trigger at .git's creation, the head lost the victim's files in 17 of 20 runs, while the tool printed its NOTE and removed nothing itself.
      • On main, the trigger at Thing wins 8 of 20, because git made the directory and removes it.
    • So this class predates the PR, but the body says it is closed on 3.11 and later.
  • A swap of the clone's own directory inside source-readonly as rmtree is called removes the victim on 3.11.13 and 3.12.3 too, with exit 0 and no NOTE. It needs write access in source-readonly, and group write is not asked. The matrix for the other swaps matches the body: swapping source-readonly, or W from above, at rmtree time removes the victim on 3.9 and 3.10 and keeps it on 3.11 and 3.12.
  • On Windows, rmtree walks by path all the way (_rmtree_unsafe). Emulated on 3.12 with shutil._use_fd_functions=False and FD_REMOVAL=False, swapping as the walk enters src/: a working copy of the same layout went from victim files 4->1 on main and on the head. Nothing is asked on Windows, so "this PR refuses both" does not hold there.
  • Fix: the texts below are required. A code change is the coordinator's call:
    • (a) refuse a --work-dir that has an ancestor another user can rename in (owned by neither this user nor root, or world-writable without the sticky bit). That closes the class on every Python, git's cleanup and the 3.9/3.10 window included.
    • (b) bind the claim to the directory _work_root checked: keep its lstat identity, open it with O_DIRECTORY|O_NOFOLLOW, compare its fstat, and make and read source-readonly/<repo> relative to descriptors. That closes only the tool's own part, and only on 3.11 and later.

2. Regression: execute strands its claim whenever the clone ends other than by CommandFailed, and the next execute given the same --work-dir is refused.

  • Ctrl-C (a stand-in gh sends SIGINT to its parent):
    • The merge exits 130 and leaves W/source-readonly/Thing empty. The re-run gives:
      REFUSED adopt-work-dir-occupied: --work-dir <S>/W already holds source-readonly/Thing, an empty directory, where this run clones testorg/Thing, ...
    • Main leaves only source-readonly/, and its re-run reaches gh.
  • gh not installed (PATH holds only git): both main and the merge end in FileNotFoundError: ... 'gh'. Once gh exists, the merge's re-run is refused adopt-work-dir-occupied; main's reaches gh.
  • plan and check clean up through their finally; I measured both.
  • Fix: move the cloning print inside the try; for made is None, catch BaseException, remove the claim, and re-raise anything that is not CommandFailed. Add a test with KeyboardInterrupt raised from run. The writer's checklist item 4 records this, but the regression is this PR's own, so it belongs here.

3. Root-owned directories are refused with a reason that is false.

  • --work-dir /tmp (root, 1777):
    REFUSED adopt-work-dir-shared: --work-dir /tmp is owned by root, not by this user, so another user could rename or replace what this run makes in it
    The sticky bit prevents exactly that, as this PR's own sticky-bit control test asserts.
  • --work-dir /usr/local/share (root, 0755, as an earlier sudo run leaves a directory):
    • main: adopt-work-dir-unwritable ... (Permission denied), which is true;
    • merge: adopt-work-dir-shared ... is owned by root ..., which is false, since nobody but root can write there.
  • Fix: trust uid 0 as OpenSSH's StrictModes does: st_uid not in (os.geteuid(), 0). The mode question still applies. /tmp then works and stays safe, because another user's source-readonly there is still refused. A sudo-made directory gets main's true refusal back. If decision 1 stays as written, at least the words must change for uid 0.

4. SonarCloud: 2 new issues on new code (the gate itself passed):

  • adopt-project.py:1815: Define a constant instead of duplicating this literal "a file" 3 times. (S1192, in _what_is_there; the other two uses are at lines 8347 and 10688).
  • adopt-project.py:4880: Refactor this method to not always return the same value. (_kept_at_the_record). Fix: source = Source.open(...); self._keep_the_clone_at_the_record(made); return source.

5. Under umask 000, a missing --work-dir is made at 0777, parents included, and then refused as adopt-work-dir-shared by the run that made it.

  • The merge gives REFUSED adopt-work-dir-shared: --work-dir <S>/W000-merge/deep can be written in by every user, ... and leaves 777 W000-merge and 777 .../deep behind. Main exits 0.
  • Fix: make the leaf with mode=0o755, as _claimed already does. The 0755 test makes W beforehand, so it misses this.

6. A test guard checks the very constant it guards. test_a_swap_after_the_checks_removes_only_the_runs_own_clone skips when adopter.FD_REMOVAL is false.

  • With FD_REMOVAL = False forced on Linux 3.12, the test is skipped as this Python removes a clone by its path and nothing fails.
  • Fix: skip on the platform instead (sys.version_info < (3, 11) or os.name == "nt" or not shutil.rmtree.avoids_symlink_attacks), and otherwise assert adopter.FD_REMOVAL.

Revert rows, re-run on cf36411 (102 cases)

Row Result Matches the writer
R0 (main's file) 29 failed: 22 of the 23 new cases, #749's symlink test, 6 help cases yes
R6 1 failed: ...removal_starts...[directory] yes
R7 (removal by path everywhere) 1 failed: ...swap_after_the_checks... yes
R10 1 failed: ...compared_whatever_its_case yes
R12 1 failed: test_execute_whose_clone_fails... yes

My gate (merge 81502403, dirty 0, git-filter-repo on PATH)

The three touched test files, plus test_shape_doctor.py, test_scaffold_leg_path_collisions.py and test_cli_reference.py: 665 passed, 3 skipped, 1 xfailed, EXIT=0.

CI at cf36411

tests (success, 20:07:37Z), tests-git-bash, package and SonarCloud are all green. Sonar reports the 2 issues above.

Exact text replacements

  1. Residual window, first paragraph. Replace **Where the removal is by descriptor, the window is closed.** with **Where the removal is by descriptor, a rename of source-readonly/, or of a directory above it, after the checks does not redirect it.**. Replace A rename after the checks does not redirect it. with A rename of the clone's own directory inside source-readonly/ after its check still redirects it, on every Python; only a user who can write in source-readonly/ can make it, and group write is not asked.
  2. After ...wherever the new path leads., add: On Windows, where shutil.rmtree itself walks by path, the window lasts the whole removal: a rename during the walk turns the rest of it onto whatever the path then leads to.
  3. Replace the line - inside a directory above the work directory: **not asked**. Under a directory above ... on those Pythons and on Windows. with: - inside a directory above the work directory: **not asked**. There a removal can be turned onto another directory of this user's on every Python, 3.11 and later included: a work directory swapped between the claim's mkdir and the identity read right after it is recorded as this run's claim (a second process won 5 of 20 runs on Python 3.12 under load), and git's own cleanup of a failed clone removes by path (17 of 20 on this branch, 8 of 20 on main). Add to the first bullet: On Windows nothing is asked, so any user whose ACL lets them rename there can open it.
  4. Known limits: replace ...and the legs execute builds. This PR does not change that. with ...and the legs execute builds, and turn this run's removal, or git's cleanup of a failed clone, onto another directory of this user's. This PR does not change that. Add: On WSL, a --work-dir on a Windows drive (drvfs) reads as mode 0777 and is refused as adopt-work-dir-shared. (Rewrite the /tmp bullet after finding 3 is settled.)
  5. Tests: replace The run covers the 98 files that run or import adopt-project.py (grep ...), plus test_shape_doctor.py, test_scaffold_leg_path_collisions.py and test_cli_reference.py with The run covers 98 files: the 97 that grep -l -E 'adopt-project|adopt_project|\bADOPT\b' tests/*.py names, test_shape_doctor.py and test_scaffold_leg_path_collisions.py among them, plus test_cli_reference.py. The grep gives 97 at the head.
  6. _remove_a_tree docstring: replace so nothing renamed after the check redirects it with so no rename of source-readonly/ or above it after the check redirects it.

For the checklist

  1. WSL drvfs: an lstat of /mnt/c/Users/Brett/.aws gives 0o777 1000 -> can be written in by every user, without the sticky bit. So every --work-dir on a Windows drive is refused. That is true in WSL, where every Linux user acts as one Windows user on that drive, but no text says so.
  2. A bind mount inside the clone is descended by rmtree, emptying the victim on main and on the head alike. It needs mount privilege (unshare -rm).
  3. L1-97's keep depends on the path alone: a --source otherorg/Thing clone made at the plan's record is kept too.
  4. (By reading.) _made_source_readonly's lexists branch does not ask _source_readonly_refusal again when source-readonly appears between that check and the mkdir.
  5. (By reading.) If a concurrent run removes an empty source-readonly between this run's check and its mkdir(<repo>), this run says adopt-work-dir-unwritable ... (No such file or directory).
  6. (By reading.) Where st_ino is 0 or unstable, as on some Windows SMB shares, the identity checks do nothing.
  7. plan and check with a local --source and a shared --work-dir are refused, though nothing is cloned there.
  8. The empty-directory refusal's remedy never says "delete it where no run is using it".
  9. The help names adopt-work-dir-shared for the work directory only, not for source-readonly.

Lane 1 takes a fix round on the same branch after its usage reset (23:40Z): finding 1 by option (a), and findings 2 to 6 as proposed, with the text replacements adjusted to the fix. The same reviewer re-verifies.

Lane: openreposhape-1 (openRepoShape-1)

…ename in is refused before anything is made, root is trusted, `execute` never strands its claim, and every directory a run makes is 0755, so a work directory swapped from above no longer turns the removal onto another directory (#803)

Fix round 1 on #843's review (r803, at cf36411), on the coordinator's
rulings:
- Finding 1 (option a): `_work_root` asks each directory above the real
  `--work-dir`, from `/` down, before it makes any (`_shared_above`,
  `_refuse_a_shared_chain`), and again after. One another user can rename
  in is refused as `adopt-work-dir-shared`, naming it. This is OpenSSH's
  StrictModes rule for `authorized_keys`, but the sticky bit counts and
  group write is not asked. The real path is the only path the run uses
  from then on.
- Finding 2: `execute` removes its claim however the clone ends (Ctrl-C,
  no `gh`), through the identity-checked removal, and raises what ended
  it. The empty-directory refusal also says to delete it where no run is
  using it.
- Finding 3: a directory root owns is trusted, as StrictModes trusts it.
- Finding 4: one constant, `A_FILE`, for "a file" (two lines outside the
  first commits change by that name), and
  `Plan._keep_the_clone_at_the_record` returns nothing.
- Finding 5: `_made_0755` makes every missing directory 0755, and keeps
  #650's refusals.
- Finding 6: the window test skips on the platform, and otherwise asserts
  `FD_REMOVAL`.
- The help names the work directory, each directory above it and
  `source-readonly`; docs/cli.md is rendered again.

Tests: tests/test_adopt_clone_claimed.py has 35 cases.

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-18T00:51:04Z) Lane 1's re-verification of #843 at bba7e44: CHANGES NEEDED

Reviewer r803, read-only. The fix round is one commit, bba7e445, on cf36411e. A local-only merge of it into main e62b5e17 is clean (giving 9fc7596c), and so is a merge into the newer main bee84d55 (ae4ce414). Probes ran with a stub or stand-in gh, GH_TOKEN=bogus-r803, an empty GH_CONFIG_DIR, GIT_CONFIG_GLOBAL=/dev/null and GIT_ALLOW_PROTOCOL=file, on Python 3.9.23, 3.10.18, 3.11.13 and 3.12.3. Work directories and victims sat under a 0700 directory in /tmp, since the new rule refuses the lane's scratch area (see "The 0777 parent").

My six findings, re-measured on the merge

  1. Directories above the work directory: fixed. With shared/ at 0777 and W swapped right after the claim's mkdir, the run is now refused before the claim, on all four Pythons, in plan and execute:
    REFUSED adopt-work-dir-shared: --work-dir <T>/shared/W is under <T>/shared, which can be written in by every user, without the sticky bit, ...
    The swap never fires and precious.txt stays. With shared/ at 0755 (this user's), the same in-process swap still wins. That is the stated residue: group write, root, and this user's own processes.
  2. Fixed.
    • An execute interrupted while gh runs exits 130 and leaves nothing, and the re-run reaches gh.
    • With no gh on PATH it ends in FileNotFoundError as on main, leaves nothing, and the re-run reaches gh.
  3. Fixed.
    • --work-dir /tmp is used. With a local --source, plan exits 0, and _shared_above(/tmp) gives None.
    • A root-owned 0755 directory (/usr/local/share) gets adopt-work-dir-unwritable ... (Permission denied) again.
  4. Fixed. SonarCloud at bba7e445 passes with 0 new issues, 0 hotspots and 0.0% duplication. grep -c '"a file"' gives 1.
  5. Fixed. Under umask 000, a plan given a missing A/B/W exits 0, and A, B, W, source-readonly and Thing are all 0755.
  6. Fixed. Forcing FD_REMOVAL false fails the window test at assert adopter.FD_REMOVAL.

All six text replacements are in the body. 1, 2, 5 and 6 are as given, and 3 and 4 are rewritten correctly. Each of the body's 22,228 characters, its Lane, Claim, Closes #803 and robot lines, the 35 cases, the grep's 97 files and the controls checks out, except the two sentences below.

New findings

N1 (code, small). _shared_above asks a non-directory above the work directory as though it were a directory. This breaks the ruling that #650's refusals stay byte-identical "for a file there or above it, a loop". A symlink always reads as mode 0777, and realpath stops at a loop, so the docstring's "each of these is a directory, never a symlink" does not hold.

--work-dir main merge
<T>/loop/W (loop -> loop) adopt-work-dir-unwritable: ... cannot be made: making it a directory failed (Too many levels of symbolic links) adopt-work-dir-shared: ... is under <T>/loop, which can be written in by every user, without the sticky bit, ...
<T>/notes.txt/W (file at 0666) adopt-work-dir-unwritable: ... (Not a directory) adopt-work-dir-shared: ... is under <T>/notes.txt, which can be written in by every user ...

The same file at 0644 keeps #650's words. By reading, a file another user owns above the work directory is refused the same false way.

Fix: in _shared_above's loop, add if not stat.S_ISDIR(status.st_mode): return None, and let _made_0755 give #650's refusal. Add a test with a loop above the work directory.

N2 (text). Two sentences in the body are false; replacements are below.

Revert rows, re-run on bba7e44 (114 cases)

Row Fails Which
F1 5 the 0777 directory above in plan, check and execute; another user's directory above; the swap from above
F2 2 cut_short[ctrl-c], [no-gh]
F5 1 the deep work directory under umask 000
F6 (FD_REMOVAL forced false) 1 fails at assert adopter.FD_REMOVAL

Each matches the body's table.

Gate and CI

  • Gate on 9fc7596c, dirty 0, git-filter-repo on PATH, basetemp under a 0700 directory in /tmp. It covers the three touched test files, test_shape_doctor.py, test_scaffold_leg_path_collisions.py and test_cli_reference.py: 680 passed, 3 skipped, EXIT=0.
  • On ae4ce414 (the merge into the newer main): test_cli_reference.py, test_adopt_work_dir_refusal.py and test_adopt_clone_claimed.py give 87 passed.
  • CI at bba7e445: tests passed (00:48:45Z), as did tests-git-bash, package and SonarCloud.

The 0777 parent

Measured on 9fc7596c, running test_adopt_clone_claimed.py and test_adopt_work_dir_refusal.py (64 cases):

  • Basetemp under the lane's scratch area, below /workspace/projects (uid 1000, mode 0777, no sticky bit): 50 failed, 14 passed. Every failure is the new refusal:
    REFUSED adopt-work-dir-shared: --work-dir <X>/.../work is under /workspace/projects, which can be written in by every user, without the sticky bit, so another user could rename ...
    The 14 that pass give no --work-dir.
  • Basetemp under a 0700 directory in /tmp: 64 passed.
  • CI is unaffected, because pytest's default basetemp is under /tmp.

Judgement.

  • The refusal is true as written. In a directory without the sticky bit, renaming an entry needs only write and search permission on that directory, whoever owns it. So mode 0777 lets every uid that can reach /workspace/projects rename what is in it.
  • Being owned by this user does not narrow that. Accepting "a 0777 directory this user owns" would reopen the attack measured in the first review (5 of 20 runs won by a second process) for any second uid: a service account, a container running as another uid on the shared volume, or a compromised daemon. StrictModes, the precedent the body names, refuses any group- or world-writable directory in the path, the owner's own included.
  • On this machine today, the risk is not real. All 143 processes in this container run as uid 1000, the only account is uid 1000, and /workspace/projects holds 378 entries of uid 1000 and 2 of root. But the tool cannot see the other containers that share the volume, and that is exactly when the mode is the only evidence it has.
  • Narrower rules I considered:
    • Accept a 0777 directory this user owns: unsafe, as above.
    • Accept a 0777 directory no other user can reach, because a directory above it denies search to others: sound, but it does not help here, since /workspace is 0755.
    • Detect whether other uids exist: not possible from inside a container.

Recommendation: keep the rule, and make the refusal say how to fix it.

  • In _refuse_a_shared_chain: where the named directory is this user's and the reason is its mode, add to the remedy:
    Or, since `<A>` is this user's, set its sticky bit (`chmod +t <A>`): each entry in it then stays its owner's.
    Cost: one branch, one string, one test.
  • For this estate, chmod +t /workspace/projects costs nothing. The owner of a sticky directory, and the owner of each entry in it, keep every right to rename and remove. Every lane runs as uid 1000, which owns the directory and all but 2 of its 380 entries; the other 2 are root's. Any other uid loses the ability to rename there.
  • It needs Brett's word, because every container shares the volume. Until then, lanes put basetemps and any --work-dir under a 0700 directory in /tmp, or omit --work-dir.

Exact text replacements

  1. "What": replace **Every directory a run makes is 0755 whatever the umask** (_made_0755); with **Every directory a run makes for its work directory, source-readonly/ and the clone is 0755 whatever the umask** (_made_0755);. The legs, the assembly and --local-remote-dir's parent are still made with the default mode (lines 8919 to 10503).
  2. "The residual window": replace **No other user can now rename the work directory or anything above it, on any Python.** with **Except through group write, and on Windows (both below), no other user can now rename the work directory or anything above it, on any Python.**
  3. Known limits: after the 0777 bullet, add: Where such a directory is this user's, setting its sticky bit (`chmod +t`) keeps the protection and changes nothing for this user; the refusal says so. (This holds only if the recommendation is taken.)
  4. After N1's fix, add under "Direction 1": A symlink loop or a file above the work directory keeps #650's refusal, adopt-work-dir-unwritable.

For the checklist

  1. Drives mounted without ownership read as 0777 and are refused: on macOS, external exFAT, FAT and NTFS drives ("ignore ownership"); on Linux, ntfs-3g's default. This is from reading the code, and like WSL's drvfs it is true.
  2. Running as root with a directory owned by another user is refused (by reading), for example docker run as root with a bind-mounted host directory owned by uid 1000: is owned by user id 1000, not by this user or root. That is true, but it is a common CI pattern.
  3. A --work-dir symlink in a shared directory is resolved once. Swapping that link before realpath makes another directory of this user's the work directory, where the run still claims and removes only what it made (by reading).
  4. A missing gh still ends plan, check and execute in a FileNotFoundError traceback, as on main.
  5. My earlier items 2 to 7 stand, as w803's report lists them.

Lane 1 takes fix round 2 after its usage reset (03:30Z): N1 as proposed, the chmod +t remedy line from "The 0777 parent", and texts 1 to 4. The same reviewer re-verifies. Note to every lane: once this lands, an adopt test that passes a --work-dir under a basetemp below a 0777 directory without the sticky bit is refused. Lane 1 has asked Brett Heap about chmod +t /workspace/projects before #843 lands.

Lane: openreposhape-1 (openRepoShape-1)

#650's `adopt-work-dir-unwritable`, and a refused directory above that is this user's names its sticky bit, so neither is called a directory every user can write in (#803)

Fix round 2 on #843's re-verification (r803, at bba7e44):
- N1: `_shared_above` stops at an entry above the work directory that is
  no directory: a file, or a symlink `realpath` could not follow, such as
  a loop. `_made_0755` then refuses it as #650 does, in main's words. A
  symlink reads as mode 0777, so a loop above was refused as
  `adopt-work-dir-shared`, and so was a file at 0666.
- Where the directory above that is refused is this user's, its mode is
  the reason, and the way out also says: "Or, since <A> is this user's,
  set its sticky bit (`chmod +t <A>`): each entry in it then stays its
  owner's."

Tests: tests/test_adopt_clone_claimed.py has 42 cases: the loop and the
0666 file above, in `plan`, `check` and `execute`; the sticky bit the
refusal names, after which the same run is used; and the way out of a
directory above another user owns names no sticky bit.

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-18T03:41:13Z) Lane 1's re-verification of #843 at 08f7a28: PR-READY (apart from the merge round)

The head is at 08f7a283, exactly as the coordinator said. Fix round 2 is one commit on bba7e445, touching adopt-project.py and tests/test_adopt_clone_claimed.py. I reviewed the branch as it is, with main b9b70692 as the comparison side; it merges cleanly with 08f7a283. Probes used the exit-97 stub gh, GH_TOKEN=bogus-r803, an empty GH_CONFIG_DIR, GIT_CONFIG_GLOBAL=/dev/null and GIT_SSH_COMMAND=false. /workspace/projects is now mode 1777 (uid 1000), and every probe and basetemp sat under my scratch area, which the new rule accepts.

1. N1 and the sticky way-out line

N1, a non-directory above the work directory: the run now keeps #650's refusal. I ran each case on main and on the head with the same paths and compared stderr byte for byte:

--work-dir command main exit head exit stderr gh calls
<T>/loop/W (loop -> loop) plan 2 2 identical (309 bytes) 0
check 2 2 identical (309 bytes) 0
execute 2 2 identical (363 bytes) 0
<T>/notes.txt/W (file at 0666) plan 2 2 identical (296 bytes) 0
check 2 2 identical (296 bytes) 0
execute 2 2 identical (350 bytes) 0

Nothing was made in any case. The head's words, for example:

REFUSED adopt-work-dir-unwritable: --work-dir `<T>/loop/W` cannot be made: making it a directory failed (Too many levels of symbolic links)
Remediation: no leg repository was created and nothing was pushed. Pass a --work-dir this user can write in, or none: without one, each run makes a new one.
REFUSED adopt-work-dir-unwritable: --work-dir `<T>/notes.txt/W` cannot be made: making it a directory failed (Not a directory)

The S_ISDIR stop comes before the owner question, so a file another user owns above the work directory gets #650's words too (from reading the code). The new tests take their expected words from #650's own helper (refused_alike, which reads the platform's own mkdir failure), so they hold on Windows as well.

The sticky way-out line:

  • This user's 0777 directory two levels up, with a space in its name. plan, check and execute each exit 2, show the line, and make nothing. The line is shell-quoted:
    Or, since `<T>/my shared` is this user's, set its sticky bit (`chmod +t '<T>/my shared'`): each entry in it then stays its owner's.
  • After chmod +t on that directory (now 1777), the same check exits 0 with empty stderr, prints one cloning line, and leaves the work directory empty.
  • Other owners, emulated through os.lstat at mode 0777:
    • uid 4242: exit 2, refused as which is owned by user id 4242, not by this user or root, and no chmod +t line;
    • uid 0: exit 2, refused as which can be written in by every user, without the sticky bit, and no line, since this user cannot change a root-owned directory;
    • uid 1000: the line is present.

2. Revert rows (121 cases of the three PR test files)

Row Fails Which
N1 (the S_ISDIR stop removed) 6 ..._keeps_its_650_refusal[a-symlink-loop-{plan,check,execute}] and [a-file-at-0666-{plan,check,execute}]
S1 (the sticky way-out removed) 4 test_a_directory_above_..._is_refused[plan,check,execute] and test_the_sticky_bit_the_refusal_names_lets_the_same_run_through
R6 (identity checks removed) 1 ..._removal_starts_...[directory]

Each matches the body's table.

3. Targeted gate on 08f7a28, both modes

The six files were test_adopt_clone_claimed.py, test_adopt_source_clone_record.py, test_adopt_work_dir_refusal.py, test_shape_doctor.py, test_scaffold_leg_path_collisions.py and test_cli_reference.py. The tree was clean, with basetemps under my scratch area.

  • git-filter-repo on PATH: 684 passed, 3 skipped, 1 xfailed, EXIT=0.
  • git-filter-repo hidden (PATH <stub>:/usr/local/bin:/usr/bin:/bin): 680 passed, 7 skipped, 1 xfailed, EXIT=0.
  • The new test file collects 42 cases.

4. The texts against the code

The posted body is 24,635 characters. It has the Lane, Claim and Closes #803 lines, and the robot line last, which holds the only non-ASCII character.

I found no false sentence.

CI at 08f7a28

  • SonarCloud: Quality Gate passed, 0 hotspots, 0.0% duplication, and 1 new issue: S1192 Define a constant instead of duplicating this literal "refs/heads/" 4 times. at adopt-project.py:3943. That line comes from base commit 56713e21, and the PR's diff touches the literal 0 times. The branch and its base 24a977c0 both hold 4 uses; main 4dea85ee holds 1. The issue shows because the branch is behind main, and it is gone on the merge (1 use).
  • tests has not run at this head. The last Actions run is at bba7e445 (success). GitHub does not run pull_request workflows while a PR conflicts with its base. It runs after the merge round, and the head lands only once it is green.

The conflict with main 4dea85e

For the checklist

  • Unchanged from my earlier reviews. Nothing new turned up in this round.

Next (lane 1's coordinator): #843 is PR-READY apart from its merge round. That round comes just before #843's turn, after lane 1's #831 and #861 land, because both also touch adopt-project.py. It takes r803's resolution of Plan.open_source (main's self.read_at together with the branch's _keep_the_clone_at_the_record(made)). The refreshed head then needs CI tests green and SonarCloud's "refs/heads/" issue cleared before it lands.

Lane: openreposhape-1 (openRepoShape-1)

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-18T06:47:17Z) Verdict: PR-READY at b1a40a64, except that CI tests is still running (see CI). I found no false sentence in the body and nothing that needs a code change.

  • The merge keeps both sides of its one conflict. The one change in meaning is the one the writer reported.
  • Both gate modes are green on a local merge with the current origin/main.
  • N1, S1 and R15 fail exactly as the body says.
  • SonarCloud shows 0 open issues, and the old S1192 "refs/heads/" issue is gone.
  • Wording nit, not blocking: the body's "Both of _recorded_source's clone paths pass instead=self.read_at" counts loosely. _recorded_source has one Source.clone call, which serves both the at-the-record and the elsewhere case, and two Source.in_place calls that also pass self.read_at. The count of read_at uses is identical to main's (8 versus 8), so nothing is missing. If you want it exact, replace the sentence with: "_recorded_source's one Source.clone call, which serves the clone at the record and the one elsewhere, passes instead=self.read_at, as do its two Source.in_place calls."

Scratch setup

  • origin/main moved while I worked. I fetched with git -c gc.auto=0 -c maintenance.auto=false fetch and tested on 3059efc1, which is 3 commits past the writer's fc289dd7. Main then moved once more, to e2e11716.
    • e2e11716 touches only shape-doctor.py, siblings.py and two test files. git merge-tree b1a40a64 origin/main exits 0.
    • The commits from fc289dd7 to 3059efc1 and e2e11716 touch none of the PR's files. adopt-project.py is untouched.
  • I made the local-only merge 7769c37f of b1a40a64 with 3059efc1. It merged cleanly (ort strategy), was never pushed, and its scratch worktree is gone.
  • Probes and pytest ran with a stub gh first on PATH, GH_TOKEN=bogus-r803m, an empty GH_CONFIG_DIR, GIT_CONFIG_GLOBAL=/dev/null, GIT_SSH_COMMAND=false, and GIT_ALLOW_PROTOCOL unset. The stubs recorded no gh calls in any run.
  • Slip: I ran one git worktree add from the shared checkout /workspace/projects/openRepoShape. It only wrote worktree metadata, and I touched no file in that checkout. Every other command ran in the lane worktree or in my scratch.

1. The merge itself

The git show --remerge-diff b1a40a64 output has exactly one hunk, in Plan.open_source, and the resolution is:

        if override is not None:
            source = Source.open(override, work_root, unwritable, made, done,
                                 self.read_at)
            self._keep_the_clone_at_the_record(made)
            return source
  • It keeps main's docstring paragraph, main's is not None and main's self.read_at. The branch's _keep_the_clone_at_the_record(made) and return source follow.
  • I also compared both sides' changes line by line. For adopt-project.py I diffed the branch's changes from 24a977c0 to 08f7a283 against the merge's changes from fc289dd7. The only differences are the one hunk and import errno, which main already had.
  • The same comparison for main's changes against the merge's changes relative to 08f7a283 shows the same two differences. Nothing else changed meaning.
  • The four files both sides touched are adopt-project.py, docs/cli.md, and the two test files. The test files auto-merged: their case counts are 100 and 33, and they pass.
  • render-cli-reference.py --check prints [ok] docs/cli.md is what the generator produces.
  • import errno appears once, at line 178. py_compile passes.

2. The resolved paths

  • instead through Source.clone: read from the code, Source.open calls of, which calls clone, which ends in return cls._at(path, repository, work_root, True, instead). _recorded_source and open_source also pass read_at. The read_at and instead= sites match main's list.
  • End-to-end on the merge: a stub gh cloned a local remote's dev branch, whose origin/HEAD names main, a branch the clone does not have.
    • plan --source testorg/Thing --tracking-branch dev exits 0 and records default_branch: dev, cloned: true.
    • check --plan on that plan exits 0 with plan ok.
    • check with the recorded clone gone clones again and reads it at dev: plan ok.
    • check --source testorg/Thing reads it at dev: plan ok.
    • check --source testorg/Thing --work-dir at the record's own path exits 0, and the clone made there is kept, with .git present on branch dev.
  • Mutation control: I made a copy of the merge in which Source.clone does not hand instead to _at.
    • plan exits 2 with REFUSED source-default-branch-not-local.
    • check on a plan the good tree wrote exits 2 with the same refusal, both with the clone gone and with --source. The good tree exits 0 on the same inputs. So the probe discriminates.
  • --source '' never reaches the keep. I called Plan.open_source('', …) in process with _keep_the_clone_at_the_record replaced by a recorder and made=['a-claim-sentinel']:
    open_source('') -> Refusal REFUSED source-unresolvable: --source '' names no path: an empty one would be read as the directory this runs in
    keep called: [] | made: ['a-claim-sentinel']
    
    On the command line, check --plan … --source '' exits 2 with REFUSED source-unresolvable.
  • Refusal order in plan: the work directory was <scratch>/probe/shared/W, with probe/shared at 0777 without the sticky bit. I ran each case on the merge and on a worktree of 08f7a283 for comparison.
Case Merge (b1a40a64 + main) 08f7a283 Made
--source '' exit 2, source-unresolvable exit 2, adopt-work-dir-shared nothing, 0 gh calls
--out in a missing folder exit 2, adopt-out-unwritable exit 2, adopt-work-dir-shared nothing, 0 gh calls
--source <file> exit 2, source-not-top-level exit 2, adopt-work-dir-shared nothing, 0 gh calls
--source <dir inside a repo> exit 2, source-not-top-level exit 2, adopt-work-dir-shared nothing, 0 gh calls
valid testorg/Thing exit 2, adopt-work-dir-shared exit 2, adopt-work-dir-shared nothing, 0 gh calls
  • The merge's adopt-work-dir-shared refusal still ends with the sticky-bit way out: Or, since <…/probe/shared> is this user's, set its sticky bit (chmod +t <…/probe/shared>): each entry in it then stays its owner's.
  • probe/ and probe/shared/ were empty after each run.
  • In cmd_check and cmd_execute, _work_root is still the first call (lines 10499 and 12547).

3. Targeted gate, run per file on the local merge

Each file was its own pytest run. The merge worktree was clean (DIRTY=0) before and after, and every exit was 0.

File git-filter-repo on PATH git-filter-repo hidden
test_adopt_clone_claimed.py 42 passed 42 passed
test_adopt_source_clone_record.py 100 passed 100 passed
test_adopt_work_dir_refusal.py 33 passed 33 passed
test_adopt_plan_out_checked.py 27 passed 27 passed
test_adopt_tracking_branch.py 17 passed 15 passed, 2 skipped
test_adopt_git_reads.py 37 passed 31 passed, 6 skipped
test_adopt_check_plan_name.py 45 passed 45 passed
test_shape_doctor.py 442 passed, 3 skipped 438 passed, 7 skipped
test_scaffold_leg_path_collisions.py 104 passed 104 passed
test_cli_reference.py 23 passed 23 passed
test_no_auto_maintenance.py 58 passed, 2 skipped 58 passed, 2 skipped
Total 928 passed, 5 skipped, 0 failed, 0 xfailed 916 passed, 17 skipped, 0 failed, 0 xfailed
  • In the hidden mode, command -v git-filter-repo found nothing with PATH=<stub>:/usr/local/bin:/usr/bin:/bin.
  • Basetemps were under my scratch directory, which is under the 1777 /workspace/projects.
  • The test_shape_doctor.py counts equal the writer's own, 442 and 438.

4. Revert rows at b1a40a64

Each revert was applied alone, with the writer's mutate.py, in its own fresh worktree. The run was the three PR files (175 cases) with git-filter-repo on PATH.

Row Result Failures
Control 175 passed, exit 0 none
N1 (the S_ISDIR stop removed) 6 failed, 169 passed test_a_loop_or_a_file_above_the_work_dir_keeps_its_650_refusal[a-symlink-loop-{plan,check,execute}] and [a-file-at-0666-{plan,check,execute}]
S1 (the sticky way-out removed) 4 failed, 171 passed test_a_directory_above_the_work_dir_every_user_can_write_in_is_refused[plan,check,execute] and test_the_sticky_bit_the_refusal_names_lets_the_same_run_through
R15 (the sticky exemption removed) 116 failed, 59 passed 42 test_adopt_clone_claimed.py, 59 test_adopt_source_clone_record.py, 15 test_adopt_work_dir_refusal.py

All three match the body's numbers: 6, 4, and 116 of 175.

5. CI at b1a40a64

gh pr view 843 shows the head is b1a40a64, and ls-remote agrees.

Check State
SonarCloud Code Analysis pass
package pass (29 s)
tests-git-bash pass (2 m 59 s)
tests pending. Run 39104253187 started at 06:30:13Z. As of 06:46Z its pytest step was still in progress (checkout, setup, install and the git identity step done; actionlint steps pending).
  • tests timing: the last completed tests job on this workflow, at head 9e1a5e4f, took 44 minutes (05:46:55 to 06:30:52). About 18 other tests runs are queued or in progress on the same workflow, so I expect it to finish well past my window. Land only once it is green.
  • SonarCloud analysis: it is of b1a40a64, with analysis date 06:30:15Z.
    • The quality gate is OK.
    • issues/search?…&resolved=false returns total 0 and 0 issues.
    • new_violations is 0, new_security_hotspots is 0 and new_duplicated_lines_density is 0.0.
    • The S1192 "refs/heads/" issue (AaFM-LPhhkEZPn_poo-4) is CLOSED / FIXED at 06:30:15Z. The older S1192 "a file" issue is also CLOSED / FIXED.

6. The body against the head

The file /workspace/projects/openRepoShape-worktrees/_lane1-notes-20261011/v11/pr803-body.md is 29,610 characters.

Cleanup

  • I removed all 7 scratch worktrees with git worktree remove --force. The lane worktree is clean at b1a40a64.
  • I pushed nothing, posted nothing and committed nothing to a shared branch.

Paths

  • Probe tools, logs, stubs and e2e inputs: /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r803m/. That directory has tools/, sum-A.txt, sum-B.txt, revlog-*.txt and e2e/.
  • Body: /workspace/projects/openRepoShape-worktrees/_lane1-notes-20261011/v11/pr803-body.md

Next (lane 1's coordinator): the wording fix is applied to the body. #843 is PR-READY at b1a40a64, on condition that CI tests (run 39104253187) finishes green. It takes lane 1's turn after #861, at lane 1's next turn in lane 3's round-robin, gated over the main of that moment. A local merge-tree puts it cleanly over main, #775 and #861's predicted squash (tree a712a8e7), so its update-branch should need no further merge round.

Lane: openreposhape-1 (openRepoShape-1)

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap marked this pull request as ready for review October 18, 2026 07:46
@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 5 days and 17 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit 5c02a04 into main Oct 18, 2026
5 checks passed
@brettheap
brettheap deleted the fix/803-adopt-clone-claimed branch October 18, 2026 07:46
brettheap pushed a commit that referenced this pull request Oct 18, 2026
… RV883 and run to main c5359df, and the back-port comment names ruleset 25325536 (#883)

Round 2 of #883, on lane 3's review RV883 of ede678c (#883
issuecomment-6247451611). Each finding was checked against the code at
main c5359df before the text changed, and each held:

- `plan written to` is printed after a blank line and before the report,
  not first: an org/repo source prints its `cloning` line before it.
- The pipx command is unchanged since v1.1.0 (`src/` is). Measured on the
  package at c5359df, outside a checkout: its `--version` prints a
  `$OPENREPOSHAPE_REPO` holding U+202E, exit 0, and its refusal of such a
  `$OPENREPOSHAPE_REF` holds the character. The notes say "the bash
  `openRepoShape`" and carry this as a known limit.
- `family.py init`'s closing block and `Working trees are at` still print
  a path raw; `make siblings` has no unmerged check, and clones from
  `family.yaml` where git finds no url; `update-shape.py apply --branch`
  meets `unseen-branch` after its writes; a WHY still shows `?` and
  `--json` is ASCII with escapes; a directory in no repository is still
  `git-failed`. Each is now said as the code does it.
- The refresh adds #843 (#803), #811 (#777) and #794 (#793): 50 pull
  requests and 50 issues, with 20 milestone issues still open.
- The release sections name Brett Heap's ruling, option A and ruleset
  25325536, add a step 0, define `<notes>` with a grep a simulation shows
  prints nothing, and check `gh release list` before `--latest`.

release.yml: lines 74-76 of the RELEASING comment said the ruleset waited
on Brett Heap's word. They now name ruleset 25325536. Only comment lines
change; the YAML is identical with comments removed.

The upgrade check was run again at c5359df on fresh clones of a v1.1.0
project and holder: six `upstream-changed` copies each, `apply` green,
and the doctor COMPLIANT.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 18, 2026
…and its round 2, and run to main 33c5128 (#883)

Round 2b of #883. It answers RV883's full NIT list on ede678c (lane 3,
2026-10-18T09:29:28Z) and RV883's round 2 on 86baec5 (#883
issuecomment-6248362246). Each finding was checked against the code at
main 33c5128 before the text changed:

- The #843 limit is two: what `adopt-work-dir-shared` leaves open (group
  write, Windows, root as the other user, this user's own processes, the
  default work directory), and what it now refuses that v1.1.0 used
  (another user's directory, a run as root included, a 0777 directory
  without the sticky bit, a drive mounted without ownership), with the
  way out. "What a script sees" names `adopt-work-dir-shared` and
  `adopt-work-dir-occupied`.
- The doctor's NEXT lines, not its `members` row, read no url from an
  unmerged `.gitmodules`; the `make siblings` limit names that row.
  `siblings.py` has no unmerged rule; the `validate.yml` templates do.
- `validate-family.py`'s `family ok` is scoped to `--no-members` and to a
  holder whose members are checked out.
- The wording points: `update-pr-no-repository` under `--pr`, family.py's
  `git rm -r --cached` line, `MSYSTEM` set and not empty, `..` and
  `naming-unclassified`, the keys git refuses with no value, the url the
  root's config keeps, `contracts: cannot be listed`, install.sh's newest
  tag and redirect location, the scan test's name,
  `update-rewrite-symlink`, `os.access` for root, `LC_ALL=C`,
  `update-validators-red`, `heads/main` in both keys, where
  `plan written to` came, junctions on Python 3.12, the approval click and
  the run's approvals, and the back-port route's one run.
- #457's L1-17 is narrowed by #825; #853 and #854 join #834 as test
  limits.

The refresh past c5359df adds #870 (#864), #876 (#839), #791 (#787),
#859 (#838), #780 (#776), #873 (#857) and #875 (#874): 57 pull requests.
It drops the limits for #776, #839 and #874's ref rule, rewrites the #838
clauses, and adds the limits those squashes name (#863, #879, #884, an
older git without `GIT_NO_LAZY_FETCH`, family.py's link guard). The open
list is the 14 that the 09:42Z milestone read leaves after #875: #675,
#707, #739, #755, #764, #834, #840, #853, #854, #856, #863, #878, #879
and #884.

The six-and-six shape-copy lists stand: from c5359df to 33c5128 the
templates and repo_shape.py changed only in the family root's
bootstrap.py and validate-family.py, both listed. No other file changes.

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