Skip to content

siblings.py and shape-doctor.py: a Windows junction to nothing at ../<Project> is NOT A CLONE, as a link to nothing is, so the dry run no longer says would clone and the doctor's members row no longer calls the slot absent (#793) - #794

Merged
brettheap merged 12 commits into
mainfrom
l3-793-junction-slot
Oct 18, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-3 (openRepoShape-3)
Claim: #793 (comment)

Closes #793.

Stacked on #788 (#784). Lands after the v1.1.0 tag.

Round 2 (NIT): RV-d's N2. A junction to a live folder is now tested in both tools, so the junction check is pinned after exists(): all four mutants that ask it first fail (2ffe154c, tests only). N1, the commit subject, is left as it is, since the squash takes this PR's title.

What changed, and why

#788 tells a link to nothing from an empty slot with Path.is_symlink(), which says no to a directory junction, the link Windows makes without privilege. So a junction to nothing at ../<Project> still read as an empty slot: the dry run said would clone, the real run tried to clone into it, and the doctor's members row called the slot absent and said make siblings places the member there. That is #784's bug, for a junction.

Both places now also ask the junction check, spelled as family_link spells it (siblings.py:985 on this branch; the issue's :931 is main's line): getattr(os.path, "isjunction", lambda _: False). Python before 3.12 has no isjunction, so the fallback says no there. Each script stays self-contained.

templates/family-root/scripts/siblings.py:

shape-doctor.py:

  • unclonable (:7974) asks it at :7999-8001, so it answers dangling, sibling_slot (:8009) says not-a-clone, and the row says "is a link to nothing" from UNREADABLE_BESIDE (:8179, "dangling" at :8181). The doctor and make siblings word it alike. The docstring says so at :7992-7995.

What is tested

The junction is simulated, as test_a_family_folder_reached_through_a_windows_junction_passes simulates one, so every system asks it: os.path.isjunction is patched (raising=False) to say yes for the slot path only. For a junction to nothing the slot does not exist; for a junction to a folder (round 2) it is that folder.

  • tests/test_family_siblings_placement.py:

  • tests/test_shape_doctor.py:

    • test_the_row_names_a_windows_junction_to_nothing_a_link_to_nothing (:4203) asserts that the members row says not-a-clone (:4213), names the slot with UNREADABLE_SAYS["dangling"] (:4214), and says nothing of what make siblings does there (PLACES_NOTE, :4216).
    • Round 2: test_the_row_reads_a_windows_junction_to_a_folder_as_that_folder (:4219-4235) checks a junction to a folder. It asserts the words test_the_row_says_what_make_siblings_does_with_each_slot asserts for the same folder without a junction (:4233-4235):
      • an empty folder is absent, with PLACES_NOTE;
      • a folder holding files is not-a-clone, in SLOT_SAYS["not-a-clone"]'s words, without PLACES_NOTE.
    • Both tests share junction_row (:4190).
  • Red first: with siblings.py and shape-doctor.py: a link to nothing at ../<Project> is NOT A CLONE, reported and left as it is, so the dry run no longer says would clone where the real run ends CLONE FAILED, and the doctor's members row no longer calls the slot absent (#784) #788's head's (e966a684) siblings.py and shape-doctor.py, all three fail (3 failed, 29 deselected):

    • the dry run: 'would clone' == 'NOT A CLONE';
    • the real run: 'CLONE FAILED' == 'NOT A CLONE';
    • the doctor: 'absent' == 'not-a-clone'.

    With the fix, all three pass (3 passed, 29 deselected).

  • Round 2, the order: four mutants ask the junction check before exists(), each run on its own against the seven junction cases.

    • The mutants: in each script, the junction check alone moved above it, or the whole is_symlink() or junction() test moved above it.
    • siblings.py: each mutant fails both placement folder cases ('NOT A CLONE' == 'would clone', and the folder-of-files finding).
    • shape-doctor.py: each mutant fails both doctor folder cases ('not-a-clone' == 'absent', and the folder's words).
    • Each mutant run ends 2 failed, 5 passed, 29 deselected. The round-1 junction-to-nothing tests pass under all four mutants, which is why N2 found the order unpinned. With the fix as it is, all seven pass (7 passed, 29 deselected).

Commands, with a stub gh (exit 97) first on PATH, GH_TOKEN=bogus and an empty GH_CONFIG_DIR, at 2ffe154c, on #788's head e966a684:

/usr/bin/python3 -m pytest -q -p no:cacheprovider --basetemp=<scratch>/btA -o tmp_path_retention_policy=failed tests/test_family.py tests/test_family_siblings_placement.py tests/test_siblings_family_name.py tests/test_family_makefile_siblings.py tests/test_park_resume_targets.py tests/test_windows_paths.py tests/test_family_printed_quoting.py tests/test_update_shape_add.py tests/test_update_shape_kept_helper.py tests/test_repo_hygiene.py tests/test_cli_reference.py
727 passed in 151.46s (0:02:31)

/usr/bin/python3 -m pytest -q -p no:cacheprovider --basetemp=<scratch>/btB -o tmp_path_retention_policy=failed tests/test_shape_doctor.py tests/test_doctor_members_spelled.py tests/test_doctor_gitmodules_not_utf8.py
476 passed, 3 skipped in 363.66s (0:06:03)

Pinned shape copies

templates/family-root/scripts/siblings.py is a family-root copy carried by digest. A holder pinned before this change shows an upstream-changed row for it until update-shape.py brings it forward. No other pinned copy is touched.

Notes

  • Not measured: Windows itself. Lane 3 has no Windows machine, and CI is Linux-only since ci: run the test suite on Linux only #597, so what a real clone does onto a dangling junction, and whether Path.exists() says no for one, are not measured here. The tests simulate the junction.
  • Python 3.9 to 3.11 on Windows still read a junction to nothing as an empty slot. requires-python is >=3.9, and os.path.isjunction is 3.12's. The fallback says no there, as family_link's does, as the issue asks.
  • No docs change: no doc describes these slots or quotes the NOT A CLONE finding, and siblings.py's --help is unchanged.

🤖 Generated with Claude Code

Summary by Sourcery

Handle dangling Windows junctions consistently as non-cloneable sibling slots across placement and diagnostics.

Bug Fixes:

  • Treat dangling Windows directory junctions as links to nothing rather than empty sibling slots, preventing clone attempts and correcting the shape doctor’s member status and guidance.

Enhancements:

  • Preserve normal handling of junctions targeting existing empty or populated folders by checking junction status only for non-existent paths.

Tests:

  • Add coverage for dangling and live Windows junctions in sibling placement and shape-doctor member reporting, including dry-run and real-run behavior.

Brett Heap and others added 3 commits October 17, 2026 00:53
…, at `../<Project>` is NOT A CLONE, reported and left as it is, so the run no longer ends in a traceback at `iterdir` before the members after it are placed, and `make park` no longer ends in one over a folder it cannot enter (#735)

`place` asked only whether a folder had something in it: `iterdir` raised
`NotADirectoryError` for a file and `PermissionError` for a folder with
mode 0311 or 000, a traceback, exit 1. `not_a_clone` now gives the
finding's words for each slot nothing is cloned into: the folder of files
keeps its finding byte for byte, a file "is a file, not a folder", and a
folder it cannot list "is a folder that cannot be listed", the words the
doctor's `members` row says of the same slots. `holds_git` asks for a
`.git` and reads an `OSError` as none, for `place` and for `dispatch`
(`--make`), which Python 3.12 answered with `PermissionError` in a folder
that cannot be entered.

The doctor's agreement test (#705) now passes for a file and for both
locked folders, so its strict xfail is gone; the doctor's output is
unchanged, and its comments now say a holder pinned before this change
still ends in the traceback.

`siblings.py` is a family-root copy carried by digest: a holder pinned
before this change shows an `upstream-changed` row for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…is NOT A CLONE, reported and left as it is, so the dry run no longer says `would clone` where the real run ends CLONE FAILED, and the doctor's `members` row no longer calls the slot `absent` and says `make siblings` places the member there (#784)

`exists` follows a link, so a dangling one read as nothing at all. In
`siblings.py`, `not_a_clone` now answers "is a link to nothing" for it, in
the finding the other NOT A CLONE slots get. In the doctor, `unclonable`
answers `dangling`, so `sibling_slot` says `not-a-clone`, and the row says
"is a link to nothing" and nothing of what `make siblings` does there: a
holder pinned before this change still ends CLONE FAILED on it. `make
park` and `make resume` keep `SKIPPED no clone`.

`siblings.py` is a family-root copy carried by digest: a holder pinned
before this change shows an `upstream-changed` row for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…/<Project>` is NOT A CLONE, as a link to nothing is, so the dry run no longer says `would clone` where the real run ends CLONE FAILED, and the doctor's `members` row no longer calls the slot `absent` (#793)

#784 told a link to nothing from an empty slot with `is_symlink`, which
says no to a directory junction, the link Windows makes without
privilege. `not_a_clone` in `siblings.py` and `unclonable` in the doctor
now also ask `os.path.isjunction`, spelled as `family_link` spells it,
`getattr(os.path, "isjunction", lambda _: False)`, since Python before
3.12 has none. Both still word it "is a link to nothing", and each script
stays self-contained.

The tests simulate the junction as the family-folder junction test does:
the slot does not exist and only `os.path.isjunction` says yes. The
placement test mounts the member from a local path that does not exist,
so a run that took the slot for an empty one fails its clone offline.

`siblings.py` is a family-root copy carried by digest: a holder pinned
before this change shows an `upstream-changed` row for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 17, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR extends existing dangling-symlink handling to Windows junctions by conditionally using os.path.isjunction, preserving safe NOT A CLONE behavior in sibling placement and consistent not-a-clone diagnostics in the shape doctor, with focused simulated-junction regression tests.

Sequence diagram for Windows junction handling in sibling placement

sequenceDiagram
    participant User
    participant Siblings as siblings.py
    participant OS as os.path
    participant Slot as SiblingSlot

    User->>Siblings: place(...)
    Siblings->>Slot: exists()
    Slot-->>Siblings: false
    Siblings->>OS: isjunction(Slot)
    OS-->>Siblings: true
    Siblings-->>User: NOT A CLONE: is a link to nothing
    Siblings-->>Slot: leave junction unchanged
Loading

Sequence diagram for shape doctor junction diagnostics

sequenceDiagram
    participant Doctor as shape-doctor.py
    participant Slot as SiblingSlot
    participant OS as os.path

    Doctor->>Slot: exists()
    Slot-->>Doctor: false
    Doctor->>OS: isjunction(Slot)
    OS-->>Doctor: true
    Doctor-->>Doctor: unclonable() returns dangling
    Doctor-->>Doctor: sibling_slot() returns not-a-clone
    Doctor-->>Doctor: members row says is a link to nothing
Loading

File-Level Changes

Change Details Files
Treat dangling Windows junctions as links to nothing during sibling placement.
  • Extend dangling-slot detection with os.path.isjunction, using a Python-version-compatible fallback.
  • Return the existing NOT A CLONE finding and leave dangling junctions untouched in both dry-run and real-run placement.
  • Document the junction behavior alongside existing symlink handling.
templates/family-root/scripts/siblings.py
Classify dangling Windows junctions consistently in the shape doctor.
  • Extend unclonable detection with the same os.path.isjunction fallback.
  • Report junction slots as not-a-clone/dangling and suppress the misleading placement note.
  • Update the docstring to explain behavior across Python versions.
shape-doctor.py
Add regression coverage for junction behavior in placement and diagnostics.
  • Simulate junctions by patching os.path.isjunction for the target slot.
  • Cover dry-run and real placement, asserting no clone attempt and the existing link-to-nothing finding.
  • Assert doctor member rows use not-a-clone wording and omit the make siblings placement note.
tests/test_family_siblings_placement.py
tests/test_shape_doctor.py

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-3, 2026-10-17T10:00:51Z) Independent review of #794 by RV-d (opus), on head 5cf26fcb (stacked on #788 e966a684): READY. No MUST, no SHOULD, two NITs.

Checked:

NITs:

  • N1: the commit subject of 5cf26fcb says "where the real run ends CLONE FAILED", which is unmeasured for a junction. The simulated CLONE FAILED comes from the missing local remote. Squash with the PR title, which leaves it out.
  • N2: no test pins the junction check after exists() (siblings.py:499-501, shape-doctor.py:7998-8001). Both mutants that move it above exists() survive. Add a live-junction case: an existing empty slot gives "would clone" / absent, and a slot with files keeps the folder wording.

Mutants:

mutant result
siblings.py without the check killed by the dry-run and real-run tests
shape-doctor.py without the check killed by the doctor's junction test
either check moved above exists() 2 survive (N2)

Runs:

  • test_family.py, test_family_siblings_placement.py, test_park_resume_targets.py, test_windows_paths.py and test_repo_hygiene.py: 310 passed;
  • test_shape_doctor.py -k "junction or …sibling…": 44 passed.

Merges: clean with main bf1f099b. #788's head is an ancestor.

Lane: openreposhape-3 (openRepoShape-3)

… junction to a folder at `../<Project>` is read as that folder, so a junction check moved above `exists()` fails a test (#793)

Review round 2 (NIT N2 from RV-d on #794): no test pinned the junction
check after `exists()`, and both mutants that ask it first survived. A
junction to a live folder is now asked about too, with
`os.path.isjunction` saying yes for a slot that exists:

- an empty folder: the dry run says `would clone`, and the doctor's row
  says `absent`, with the note that `make siblings` places the member;
- a folder holding files: `place` gives the finding a folder of files
  gets ("exists, is not empty and is not a git clone"), and the row says
  `not-a-clone` in a folder's words.

The setup the round-1 tests had is shared through
`placed_at_a_junction` and `refused` in the placement tests and
`junction_row` in the doctor's, and those tests assert what they did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-17T10:12:20Z) #794's NIT round, 5cf26fcb..2ffe154c: N2 is closed, and RV-d's READY stands. Lane 3 checked it directly; RV-d was not resumed for a test-only round.

  • Test files only: a plain fast-forward that changes tests/test_family_siblings_placement.py and tests/test_shape_doctor.py (+92/−25). No script changed.
  • The new cases patch isjunction to say yes for a slot that exists:
    • an empty folder gives would clone / absent;
    • a folder holding files gives NOT A CLONE / not-a-clone, in the folder's own words.
  • Mutants: all four "junction before exists()" mutants, in either script, now fail 2 cases each. Round 1's tests pass under all four, which is the gap N2 named.
  • No network: both tests share placed_at_a_junction (:582), which mounts the member from a local path that does not exist. A regression's clone fails offline.
  • W669's runs: 727 passed (the family set) and 476 passed, 3 skipped (the doctor set). Each is round 1's count plus the 2 new cases.
  • N1 is unchanged by design: lane 3 squashes with the PR title.

Lane: openreposhape-3 (openRepoShape-3)

This was referenced Oct 17, 2026
brettheap and others added 4 commits October 17, 2026 19:40
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… four files #772 and #788 both changed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Base automatically changed from l3-784-dangling-slot to main October 18, 2026 03:44
brettheap added a commit that referenced this pull request Oct 18, 2026
…is NOT A CLONE, reported and left as it is, so the dry run no longer says `would clone` where the real run ends CLONE FAILED, and the doctor's `members` row no longer calls the slot `absent` (#784) (#788)

Closes #784.

A dangling symbolic link at `../<Project>` read as an empty slot, because
`exists` follows a link. `make siblings --dry-run` said `would clone`, the
real run ended `CLONE FAILED` (git cannot clone onto a link to nothing, exit
128), and the doctor's `members` row called the slot `absent` and said
`make siblings` places the member there.

Now `not_a_clone` in `templates/family-root/scripts/siblings.py` answers "is a
link to nothing", so `place` reports `NOT A CLONE`, in the finding the other
such slots get, and leaves the link as it is. `make park` and `make resume`
keep `SKIPPED no clone`. In `shape-doctor.py`, `unclonable` answers
`dangling`, so `sibling_slot` says `not-a-clone` and the row says "is a link
to nothing", and the doctor and `make siblings` agree again.
`tests/test_shape_doctor.py` and `tests/test_family.py` hold a new slot,
`a-link-to-nothing`, skipped on Windows.

Lane 3's independent reviewer RV-d (opus) found it READY on `e966a684`, then
stacked on #772 (#788 issuecomment-6230059337). Its optional NIT N2, that a
dangling Windows junction still reads as an empty slot, is #793, in #794.

After #772 landed as `112eeed2`, lane 3 restacked the branch as `bec71bb5`
(#788 issuecomment-6243557591), in three merges: `f204abe1` takes #772's final
head `bcb8e54a`; `5f7ea9b3` takes the squash `112eeed2`, ours for #772's four
files, on which `git diff bcb8e54 112eeed` is empty; `bec71bb5` takes main
`e62b5e17`. Against main the diff is #788's four files, the delta RV-d
reviewed, `1d149933..e966a68`, patch for patch (+120/-53). On `bec71bb5`, the
nine family and siblings files of its gate set pass (607 passed) and
`tests/test_shape_doctor.py` passes (441 passed, 3 skipped). The restack
carries lane 3's own check, not a reviewer's verdict.

It lands on Brett Heap's word "merge lane 3's v1.1.1 drafts after the v1.1.0
tag" (2026-10-17T13:00:57Z).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brett Heap and others added 2 commits October 18, 2026 03:46
…ep 1 of 3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…k step 2 of 3)

Conflicts in shape-doctor.py and templates/family-root/scripts/siblings.py
were taken ours: #788's final head 5854aeb and its squash 0905eef have the
same tree, so the branch already holds the squash's version of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-18T04:01:48Z) #794 is restacked after #788's squash (0905eef), as 56c1507c, by lane 3's three-merge recipe (writer R794, opus-xhigh):

  1. 6ad953c6 merges siblings.py and shape-doctor.py: a link to nothing at ../<Project> is NOT A CLONE, reported and left as it is, so the dry run no longer says would clone where the real run ends CLONE FAILED, and the doctor's members row no longer calls the slot absent (#784) #788's final head 5854aebe. It is clean.
  2. 56c1507c merges the squash 0905eef5, taking ours only in siblings.py and shape-doctor.py: a link to nothing at ../<Project> is NOT A CLONE, reported and left as it is, so the dry run no longer says would clone where the real run ends CLONE FAILED, and the doctor's members row no longer calls the slot absent (#784) #788's two files that conflicted, shape-doctor.py and templates/family-root/scripts/siblings.py, as predicted. The squash and the final head have the same tree, 5d9c2750, and this merge changed none of siblings.py and shape-doctor.py: a link to nothing at ../<Project> is NOT A CLONE, reported and left as it is, so the dry run no longer says would clone where the real run ends CLONE FAILED, and the doctor's members row no longer calls the slot absent (#784) #788's four files.
  3. Main was 0905eef5 itself, so the third merge was "Already up to date".

Lane: openreposhape-3 (openRepoShape-3)

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap marked this pull request as ready for review October 18, 2026 08:09
@brettheap
brettheap merged commit c5359df into main Oct 18, 2026
4 checks passed
@brettheap
brettheap deleted the l3-793-junction-slot branch October 18, 2026 08:09
@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 1 day and 16 hours by commenting @sourcery-ai review. Upgrade to get a review now.

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>
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