Skip to content

family.py: add mounts with --force and _commit_one stages with git --literal-pathspecs add -f, so an operator whose own excludes name members, members/, * or the member gets one commit instead of exit 2, bump moves a pin under them too, and each refusal --force turns off is made before the member is read (#242) - #308

Merged
brettheap merged 2 commits into
mainfrom
fix/family-add-force-past-excludes-on-main
Oct 12, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-4 (openRepoShape-4)
Claim: #242 (comment)

What changes

family.py add mounts the member with git submodule add --force, and every stage add, bump and remove make goes through shape_materialize.force_stage (git --literal-pathspecs add -f -- <paths>). An operator whose own excludes (a global core.excludesFile) name members, members/, * or the member gets one commit instead of exit 2, and bump moves a pin under them too.

The review, M1/S1, and the replay

  1. The first commit was reviewed alone. 289e542 was written stacked on family.py: add asks every refusal of the holder and of a throwaway clone of the member before it mounts anything, and unwinds and names whatever fails after the mount, so a refused add (a leg, a short or unknown --at, an empty member) no longer leaves a staged gitlink and .gitmodules section that the next add commits under its own title (#203) #236's head cc83d15. It was reviewed adversarially (review242-289e542): the diff was right on its own base, but the verdict was NOT MERGEABLE on M1. Merged with main, bump under members exited 2 at main's own unforced git add from family.py: bump to the commit a member is already pinned at says nothing to do and exits 1, so a re-run of a bump that landed is no longer REFUSED a git or gh command failed, exit 2, around git's nothing to commit (#210) #219, which the stacked branch never saw, and its own bump test failed. S1: once update-shape.py: apply --add force-stages its additions past the operator's own excludes, and a branch, stage or commit that fails before the commit lands now puts back the copies, the pin, the README line, the index and the branch and names the step, so check no longer exits 0 over an uncommitted re-pin (#202) #233 landed, _commit_one's hand-written forced add should become a call to force_stage.
  2. The replay. family.py: add asks every refusal of the holder and of a throwaway clone of the member before it mounts anything, and unwinds and names whatever fails after the mount, so a refused add (a leg, a short or unknown --at, an empty member) no longer leaves a staged gitlink and .gitmodules section that the next add commits under its own title (#203) #236 squash-landed as 6d8d88b, after family.py: add refuses a case-variant of a member the family already pins as member-already-present naming the row that pins it, bump and remove find a member in any case, and validate-family.py refuses two rows with one id, so --member inkrouter/irrs beside InkRouter/IRRS no longer mounts one project twice past both gates and bump --member irss no longer recommends that add (#206) #221. 289e542 was then cherry-picked onto that main on this new branch (fix/family-add-force-past-excludes-on-main), and the old branch is left where it is. There were 2 conflict hunks. family.py add runs git submodule add without --force (and _commit_one a plain git add), so under an operator's global excludes naming members or the member's own name it exits 2 at the mount for every member, and the remediation it prints blames access (gh auth status, your SSH key) instead of the ignore rule #242's side was taken for the new helpers and the _checked_mount_path call. Main's side was taken for family.py: add refuses a case-variant of a member the family already pins as member-already-present naming the row that pins it, bump and remove find a member in any case, and validate-family.py refuses two rows with one id, so --member inkrouter/irrs beside InkRouter/IRRS no longer mounts one project twice past both gates and bump --member irss no longer recommends that add (#206) #221's holding() pieces: the repository argument and the held-based remediation. The replayed commit's +/- lines against 6d8d88b are identical to git diff cc83d15 289e542 (654 lines each). The two diffs differ only in blob ids, hunk line numbers and the context lines at family.py: add refuses a case-variant of a member the family already pins as member-already-present naming the row that pins it, bump and remove find a member in any case, and validate-family.py refuses two rows with one id, so --member inkrouter/irrs beside InkRouter/IRRS no longer mounts one project twice past both gates and bump --member irss no longer recommends that add (#206) #221's pieces. The test file's blob is identical.
  3. The second commit takes M1 and S1:
    • M1: cmd_bump's re-stage is force_stage(family.root, [path]).
    • S1: _commit_one calls force_stage.
    • N1: member-path-unaddable's remediation no longer blames a symlink or submodule when git refused for another reason, such as a held index.lock.
    • N4: a test for _submodule_paths's index/HEAD fallback, which a mutant removing it used to survive.

Follow-ups the review named, none in this PR:

  • N2: the index check matches case-sensitively, where adopt uses --icase-pathspecs.
  • N3: second copies of adopt's index helpers and dry run.
  • N5: bump and remove commit the row's path: with a non-literal pathspec.
  • N6: a .gitmodules section under another name already at the mount path.

Tests

env GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t GIT_CONFIG_GLOBAL=<empty file> GIT_CONFIG_NOSYSTEM=1 PATH=<gh shim that exits 97>:<git-filter-repo 2.47.0>:$PATH /usr/bin/python3 -m pytest tests/test_family_add_excludes.py tests/test_family.py tests/test_family_add_refusals.py tests/test_shape_doctor.py tests/test_cli_reference.py -q -p no:cacheprovider: 380 passed in 499.31s, no failure and no skip, over the final tree (15c080c).

tests/test_family_add_excludes.py covers each excludes rule (members, members/, *, IRQS, IRQS/) and none:

  • add makes ONE commit of .gitmodules, family.yaml and a 160000 gitlink at the member's tip, read with git ls-tree.
  • The holder validates under the same excludes.
  • It clones recursively for an operator with no excludes.
  • bump under members moves the gitlink in one commit, and remove is the control.
  • Every guard above is refused before the member is read, with the holder unchanged.

Mutations on this head:

  • With cmd_bump's re-stage back to a plain git add, test_bump_moves_the_gitlink_past_the_operators_excludes fails at git add -- members/IRRS ("The following paths are ignored ... members").
  • Without the index/HEAD fallback, both new member-name-taken rows fail: the add blames a "previously removed" member for the other submodule's store instead.

NOTE: the symlink cases skip on Windows. Several cases run on macOS and Windows for the first time on this PR: the linked-worktree store path, a submodule at members, the held index.lock, and .gitmodules read from the index or HEAD. This head is green only when tests, tests-macos AND tests-windows all pass.

Closes #242

Summary by Sourcery

Make family member operations resilient to operator excludes while preserving safe, atomic refusal behavior for unsafe mount conditions.

New Features:

  • Allow family members to be added, bumped, and committed even when operator-configured Git excludes match their paths.
  • Add pre-mount safeguards for cached module stores, occupied paths, unaddable paths, and conflicting submodule names.

Bug Fixes:

  • Prevent forced mounts from reusing stale module stores, traversing symlinks or submodules, replacing staged index entries, or silently changing submodule registrations.
  • Ensure refusals occur before the member repository is read or modified.

Enhancements:

  • Centralize literal forced staging for family add and bump operations.

Tests:

  • Add comprehensive coverage for excluded paths, forced bump and remove behavior, mount safety refusals, Git metadata fallbacks, and cross-platform path cases.

brettheap and others added 2 commits October 12, 2026 00:33
…`git --literal-pathspecs add -f`, so an operator whose own excludes name `members`, `members/`, `*` or the member gets one commit instead of exit 2, `bump` moves a pin under them too, and each refusal `--force` turns off is made before the member is read (#242)

`add` ran `git submodule add` with no `--force` and `_commit_one` a plain
`git add`, and both read a global `core.excludesFile`. `members/` is a fixed
path, so an operator whose excludes named `members`, `members/`, `*` or the
member's name had every add of every family refused: git's dry run refused
the first four before the clone, and a directory-only rule on the member's
name (`Atlas/`) only at git's last `git add`, after it. With the mount alone
forced, `members`, `members/` and `*` still died at `_commit_one`, and so did
every `bump`, which stages `[path, family.yaml]` through it with the gitlink
already tracked. This is #174 (adopt, fixed by #200) and #229 (scaffold) on
the family side.

Both readers are forced now. `_commit_one` stages
`git --literal-pathspecs add -f -- <paths>`: `-f` reaches only the named
paths, and each is taken literally, so `-f` stages no ignored neighbour a
glob character would match (#202). That is the spelling PR #233 gives
`shape_materialize.force_stage`; #233 is not on this branch, and once it lands
the line can become a call to `force_stage`. `bump` rides with this issue: the
same line closes it.

`--force` turns off more than the ignore check, so each other thing it
changes was checked against cc83d15 (#236's head, which this branch is
stacked on) by running `add` on it and on cc83d15 with only the two `--force`
edits, on git 2.43:
- a `.git/modules/<path>` left by a removed member, which `--force`
  REACTIVATES: already refused before the mount by `member-git-dir-cached`,
  at the store `git rev-parse --git-path` names, a linked worktree's
  included. No change.
- an index entry at the path with nothing on disk: NOT refused before the
  mount. git refused it at the mount, and with `--force` it accepted a staged
  gitlink and replaced it with the member's tip, exit 0. `member-path-taken`
  now asks the index too, for any entry at the path or under it, and names
  the `git rm -r --cached` that clears it.
- a repository already checked out at the path, which `--force` REUSES:
  already refused by `member-path-taken`, on disk. It now asks `lexists`, so
  a dangling link there is refused first too instead of failing at the clone
  (the same with or without `--force`).
- the dry run `--force` skips: a symlinked `members` and a `members` that is
  a submodule were refused by git before the clone, and with `--force` the
  member was CLONED through the link (outside the holder) or into the
  submodule's tree before git refused it and the unwind deleted it.
  `member-path-unaddable` now makes that same dry run first, with `-f`, so an
  ignore rule passes it and nothing else does, as adopt's
  `_refuse_an_unmountable_path` does for its leg mounts.
- a name another submodule already has: git 2.51 and later refuse it without
  `--force` and, given it, mount the member under a new name
  (`members/Atlas1`) whose store and registration `MountRollback` would not
  find; git 2.43 rewrites the other submodule's `.gitmodules` section to
  point at the member, with or without `--force` (probed: exit 0, the other
  submodule's section gone). `member-name-taken` refuses it on every git,
  reading `.gitmodules` where git reads it, and before `member-git-dir-cached`,
  which would otherwise blame a "previously removed" member for the other
  submodule's store.
The mount carries a comment naming each of these and the check that guards
it.

The PRIVATE/access sentence (`UNREADABLE_HINT`) is printed in one place, the
probe's failed clone of the member, which no ignore rule can reach; no
excludes row prints it, before or after this change.

tests/test_family_add_excludes.py: under `members`, `members/`, `*`, `IRQS`
and `IRQS/` (the issue's `Atlas`, as this family's spare is named) and none,
`add` exits 0 with ONE commit of `.gitmodules`, `family.yaml` and a 160000
gitlink at the member's tip, read with `git ls-tree`; `validate-family.py`
passes under the same excludes; a recursive clone of the pushed holder, with
no excludes, has the member at its pin. `bump` under `members` moves the
gitlink in one commit, and `remove` is the control. Each guard is refused
before the member is read with the holder exactly as it was (the suite's
`snapshot`). Mutations: without `--force` at the mount all five rules fail
(`IRQS/` at git exit 128, after the clone); without `-f` in `_commit_one`
`members`, `members/`, `*` and `bump` fail and `IRQS` and `IRQS/` pass, as
the issue found; either one also fails the guard tests whose setup or
follow-up `add` runs under `members`. Dropping the index check, the dry run,
the name check or `lexists` fails exactly that guard's tests. Dropping
`--literal-pathspecs` fails nothing, and no test is added for it: `MEMBER_RE`
and the `--member` check (`SAFE_ARG_RE`) admit no glob character, so no
member name can hold one.

Replayed onto main 6d8d88b after #236 landed (#221's `holding()` kept).

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…re is anything to record, through `shape_materialize.force_stage`, which `_commit_one` now calls instead of spelling it, so on main a `bump` under an operator's excludes naming `members` or `*` commits instead of exiting 2 at the `git add` #219 added, and a refused dry run of the mount path quotes git instead of blaming a symlink or a submodule (#242)

Main's #219 put `run(["git", "add", "--", path])` into `cmd_bump`
before `_nothing_to_record`, to re-stage the gitlink so a stray staged
one is not a difference the commit then does not find. That is a third
reader of the operator's excludes, which #242 never saw while it was
stacked on #236.
Replayed onto main, `bump` under `members` exited 2 at that line with
`The following paths are ignored by one of your .gitignore files:
members`, and `test_bump_moves_the_gitlink_past_the_operators_excludes`
failed (the review of 289e542, M1). The line is
`force_stage(family.root, [path])` now: `-f` over the one path named,
taken literally, which stages the gitlink and never the member's files,
as before.

`_commit_one`'s hand-written `git --literal-pathspecs add -f --` is a call
to `shape_materialize.force_stage` (#233, on main since 150c41d), which
runs exactly that command, imported beside the other names family.py
takes from shape_materialize. One definition of the forced, literal
stage, and the same call closes M1 (the review's S1).

`member-path-unaddable`'s remediation no longer presumes the cause (N1).
git's dry run takes `index.lock`, so a held one refuses it, and the
refusal quoted git's `Unable to create '.../index.lock': File exists` and
then told the human to replace a symbolic link or take a submodule out,
neither of which was there. It now points at git's line and names the
link and the submodule as the cases they are.

tests/test_family_add_excludes.py: the held lock, refused before the
member is read with the holder exactly as it was and git's line quoted;
and `member-name-taken` with `.gitmodules` off the disk and the name given
only by the index's copy or HEAD's, which `_submodule_paths` falls back
to as `git submodule add` does (N4: a mutant without that fallback passed
every test). Mutations: with `cmd_bump`'s stage back to a plain
`git add`, the bump test fails again; without the fallback, both new
`member-name-taken` rows fail.

Left as follow-ups, as the review names them: N2 (the index check matches
case-sensitively where adopt uses `--icase-pathspecs`), N3 (second copies
of adopt's index helpers and dry run), N5 (`bump` and `remove` commit the
row's `path:` non-literally), N6 (a `.gitmodules` section under another
name already at the mount path).

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

sourcery-ai Bot commented Oct 12, 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 13 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Oct 12, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR makes family member mounting and gitlink commits robust against operator excludes by using forced, literal path-scoped Git operations, while adding equivalent preflight checks for all safety conditions that --force could bypass; extensive tests validate one-commit add/bump/remove behavior and unchanged-holder refusals.

Sequence diagram for forced family member add with preflight refusals

sequenceDiagram
    participant Operator
    participant Family as family.py
    participant Git
    participant Member as Member repository

    Operator->>Family: add member
    Family->>Git: _index_entries(path)
    Git-->>Family: index entries at or below path
    Family->>Git: _submodule_paths()
    Git-->>Family: .gitmodules from working tree, index, or HEAD
    Family->>Git: git add --dry-run --ignore-missing -f -- path
    alt mount path refusal
        Git-->>Family: refusal reason
        Family-->>Operator: member-path-taken or member-path-unaddable
    else preflight passes
        Family->>Git: git submodule add --force -- url path
        Git->>Member: clone member
        Member-->>Git: cloned repository
        Git-->>Family: mounted gitlink
        Family->>Git: force_stage(root, paths)
        Git-->>Family: literal forced staging
        Family-->>Operator: one commit
    end
Loading

Flow diagram for literal forced staging of family changes

flowchart TD
    A["family add, bump, or remove"] --> B["force_stage(root, paths)"]
    B --> C["git --literal-pathspecs add -f -- named paths"]
    C --> D["ignored neighbors remain unstaged"]
    D --> E["git commit"]
    F["bump re-stage before nothing-to-record check"] --> B
Loading

File-Level Changes

Change Details Files
Force submodule mounting and staging to bypass operator ignore rules while preserving literal, path-scoped behavior.
  • Mount members with git submodule add --force.
  • Centralize forced staging through shape_materialize.force_stage using literal pathspecs.
  • Apply forced staging to add, bump, and remove commit flows without staging ignored neighbors.
scripts/family.py
Add pre-mount validation for hazards that --force would otherwise bypass or defer.
  • Reject cached module stores, on-disk paths, and index entries at or below the mount path.
  • Run Git’s forced dry-run before cloning to catch symlink, submodule, and other path failures.
  • Detect conflicting submodule names from working tree, index, or HEAD .gitmodules data.
  • Keep refusals before member access and leave the holder unchanged.
scripts/family.py
Add comprehensive regression coverage for forced excludes and pre-mount refusal behavior.
  • Verify add commits exactly the manifest, .gitmodules, and expected gitlink under multiple exclude patterns.
  • Verify bump advances the gitlink under excludes and remove remains a single commit.
  • Cover cached stores, index entries, dangling links, nested submodules, locked indexes, conflicting names, and .gitmodules fallbacks across supported platforms.
tests/test_family_add_excludes.py

Assessment against linked issues

Issue Objective Addressed Explanation
#242 Ensure family member additions succeed when operator-level Git excludes match members, members/, *, or the member name by forcing both git submodule add and subsequent staging of the relevant paths. ✅
#242 Ensure shared staging operations, especially bump, also work under the same operator excludes without leaving a partially staged or uncommitted change. ✅
#242 Avoid misleading access-related remediation for mount refusals and handle other risks introduced by --force before reading or mounting the member, while preserving the private-member guidance for genuine read failures. ✅

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

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap merged commit c0ed615 into main Oct 12, 2026
5 checks passed
@brettheap
brettheap deleted the fix/family-add-force-past-excludes-on-main branch October 12, 2026 01:32
brettheap added a commit that referenced this pull request Oct 12, 2026
…s `_entry_findings` order sentence composed with #255's

#313 (71b58a1, #262 and #263) makes `check` find a kept root path where
the shape writes a directory or its `shape/` copy, through
`_root_shape_findings` called from `_leg_findings`, and makes
`shape_materialize` refuse a copy whose own path is taken. This branch
appends each value `execute` writes into a file that no file can hold
to `_entry_findings`. The one conflict is that function's docstring, the
sentence naming the order of its findings: it keeps #313's words (each
entry's `leg:`, the two mount paths and what the root keeps where the
shape writes, then the source's own submodules) and appends this PR's
clause, ", then each value `execute` writes into a file that no file can
hold (#255)". Nothing else in the file conflicts: the return still reads
`(findings + submodule_findings + unwritable_findings,
  unsafe_remediation + remediation + unwritable_remediation)`, and
`_leg_findings` carries #313's findings inside `findings`.

Main's c0ed615 (#308, family.py) comes with it and touches none of this
branch's files. Main has since moved on to c7c02fa (#315, family.py);
this merge stops at 71b58a1.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
…263's root-shape findings join `_leg_findings`, so the one list reads #196's unsafe paths first, the root's shape paths with the legs, and #190's relative urls last

Main since b421e6b: c0ed615 (#242 via #308, family.py `add --force`: family.py and
its own test only) and 71b58a1 (#262, #263 via #313: a path kept in the
root where the shape writes a directory or its `shape/` copy is a `check`
finding and an `execute` refusal before any leg exists). The one file
changed on both sides is adopt-project.py.

TWO CONFLICT HUNKS, both docstrings, each the union of both sides:
1. The module docstring's `check` paragraph: 71b58a1's "no path kept in
   the root where the shape writes (...)", then the submodules clause
   with #190's "each relative url naming the same repository from the
   leg as from the source".
2. `_entry_findings`' first paragraph: #190's "and the NOTEs it prints
   beside them", and 71b58a1's "the two mount paths and what the root
   keeps where the shape writes".

Auto-merged, 71b58a1's: the `shape_materialize` import
(`CASE_ASIDE as _CASE_ASIDE`); `_leg_findings`' docstring and its
`_root_shape_findings(plan, tree)` call; the root-shape block from
`ROOT_BLOCKS_SHAPE` to `_root_shape_findings`; and
`_refuse_what_check_finds`' remediation sentence. scripts/shape_materialize.py
and tests/test_adopt_root_shape_paths.py are main's blobs.

Proofs:
- Each side's +/- lines are unchanged by the merge except at the two
  unioned docstrings.
- `_leg_findings(plan, tree)` keeps `tree`.
- `_entry_findings` reads #196's unsafe paths, then coverage, then
  `_leg_findings` (the root-shape findings among them), then
  `_submodule_findings`, whose #190 relative urls come last. It returns
  `(findings, unsafe_remediation + remediation, notes)`.
- No top-level name is bound twice, and no line over 79 columns is added
  (42 on main, 42 merged).

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
…, #242 via #308, #262 and #263 via #313) into fix/adopt-record-submodule-placement

Brings in ede15ce (#238), 6f45bf9 (#196), b421e6b (#254), c0ed615 (#242)
and 71b58a1 (#262, #263) on top of 6d8d88b. No rebase. ede15ce changes
shape-doctor.py. 6f45bf9 and 71b58a1 change adopt-project.py and
scripts/shape_materialize.py. b421e6b changes scripts/shape_materialize.py
too, giving a project's validators an explicit PYTHONIOENCODING. All of
these are files this branch changes. c0ed615 changes scripts/family.py
alone.

No conflicts. Checked:

- adopt-project.py: #196 gives `check` and `execute` one test for an
  entry's path, `entry_path_problem`, as the finding `plan-unsafe-path`.
  #262 and #263 add `_root_shape_findings` to `_leg_findings`, for a kept
  root path where the shape writes. Both reach `execute` through
  `_refuse_what_check_finds`, before any leg exists and before
  `_commit_the_split` appends the record. This branch's `submodule_record`,
  `_resolution_text`, the record's preamble, `_answered_legs`,
  `_kept_in_a_leg`, `_commit_the_split`, `cmd_execute`,
  `submodule_plan_problems`, `_submodule_findings`, `Plan`, `y`, `emit`
  and `walk` are unchanged by the merge. `_root_shape_findings` is asked
  only beside a `legs:` mapping, so the doctor's placement plan, whose
  `legs:` is a list, still goes through `_leg_findings(plan, None)`.
- shape-doctor.py: #238 changes `validator_row` and `run_validator`. This
  branch's `check_placement`, `recorded_submodules`, `audit_leg`,
  `placement_row`, `placement_verdict` and `Context` are unchanged by the
  merge.
- scripts/repo_shape.py is untouched by these commits, so the record's
  writer and reader, and `double_quoted` in `submodule_record`, are
  3e86200's.
- Both sides added lines to the module docstrings of adopt-project.py and
  shape-doctor.py. Every line either side added is in the merge.
- No top-level name is bound twice in adopt-project.py,
  scaffold-project.py, scripts/repo_shape.py, scripts/shape_materialize.py,
  shape-doctor.py, validate-manifest.py, update-shape.py, scripts/family.py
  or scripts/bump-leg.py. Each merged module's names are the base's plus
  what either side added. Every definition only one side changed is that
  side's text.
- AGENTS.md is untouched: 472 lines, under the cap of 472.
- The branch's diff against main is still #189 alone: 12 files, +1670
  -157, file by file the same as against 6d8d88b.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
Main brings five commits since 6d8d88b:

- ede15ce (#238 via #287): shape-doctor.py's validator rows;
- 6f45bf9 (#196 via #231): `entry_path_problem`, one test of an entry's
  path for `check` and `execute`;
- b421e6b (#254 via #281): bump-leg.py and update-shape.py give the
  validators an explicit PYTHONIOENCODING (scripts/shape_materialize.py
  gains `captured_child_environment` and `inherited_child_environment`);
- c0ed615 (#242 via #308): family.py's `add` and `bump` under excludes;
- 71b58a1 (#262, #263 via #313): `check` finds, and `execute` refuses, a
  kept root path where the shape writes a directory or its `shape/`
  copy, and the materializer never writes over a copy.

It merged with no conflict. Only adopt-project.py changed on both sides.
Every other file the merge changes against this branch is main's, and
every file it changes against main is this branch's. In adopt-project.py
the merge's diff against this branch is main's own diff (559 lines), and
its diff against main is this branch's own diff (784 lines). Main's
twenty hunks there, by where they land:

1-2. the module docstring: `check` validates each entry's path and keeps
     no path in the root where the shape writes (6f45bf9, 71b58a1);
3. the `from shape_materialize import` block: `CASE_ASIDE`,
   `MOVE_THE_LEG`, `first_and_more` (71b58a1);
4-6. `_leg_findings`: a docstring line, the call to
     `_root_shape_findings`, and the new block after it (`ROOT_BLOCKS_SHAPE`,
     `ROOT_BLOCKS_COPY`, `_KeptTree`, `_shape_blocks`, `_blocked_sentence`,
     `_fix_the_kept_path`, `_root_shape_findings`) (71b58a1);
7. after `_submodule_findings`: `NOT_EXTRACTED`, `entry_path_problem`,
   `_misread`, `_refused_by_policy` and `_unsafe_path_findings` (6f45bf9);
8-11. `_entry_findings`: its docstring, and the entries' path findings
      first (6f45bf9);
12-13. `_extract_leg`: `literal:` on every `--paths-from-file` line
       (6f45bf9). This branch changed `_extract_leg` as well, further
       down: it reads the commit count and the digest before
       `_push_the_leg`. The two sets of lines do not touch, and the
       merged function has both;
14-15. `_mount_the_legs`: `git --literal-pathspecs rm` (6f45bf9);
16-17. `_leg_paths`: collected, not judged (6f45bf9);
18-20. `_refuse_what_check_finds`: its docstring and remediation
       (6f45bf9, 71b58a1).

The new findings of 6f45bf9 and 71b58a1 are `_entry_findings`, which
`cmd_execute` reaches only through `_refuse_what_check_finds`, before
`_create_leg_remotes`. So they are refused before any leg exists, and no
notice is due. The materializer's new `materialize-copy-blocked` is
raised after both legs are pushed, inside `_commit_the_split`, when a run
reaches the materializer without `check`'s finding. That makes it a late
failure, which this branch reports with the notice and exit 2. Main's
`test_with_check_skipped_execute_stops_at_the_copy_and_overwrites_nothing`
expects it to escape `cmd_execute`, so this merge breaks that test, as
9092fda's merge of #200 broke the mount-guard tests. The next commit
makes the test read the reported refusal.

No top-level name is bound twice in adopt-project.py, scaffold-project.py,
scripts/repo_shape.py, scripts/shape_materialize.py or tests/conftest.py.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
…, #238, #196, #254, #242 and #262/#263 merge with three adopt-project.py hunks kept on both sides, and #262's `_CASE_ASIDE` import kept as the name's one binding

origin/main moved b30595c -> 71b58a1:
- be2790e (#204 via #227), update-shape.py;
- 6d8d88b (#203 via #236), family.py;
- ede15ce (#238 via #287), shape-doctor.py;
- 6f45bf9 (#196 via #231): adopt-project.py `entry_path_problem`,
  scripts/shape_materialize.py;
- b421e6b (#254 via #281): scripts/shape_materialize.py, bump-leg.py,
  update-shape.py;
- c0ed615 (#242 via #308), family.py;
- 71b58a1 (#262 and #263 via #313): adopt-project.py
  `_root_shape_findings` and scripts/shape_materialize.py
  `copy_in_the_way`. It fixes what this branch's pull request listed as
  follow-ups (i) and (ii): a source's own `shape/` where the shape
  writes its copy, and a plain FILE where the shape needs a directory.

Conflicts, all in adopt-project.py, each resolved by keeping both sides:
- The module docstring's `check` paragraph. 71b58a1 adds "no path kept
  in the root where the shape writes ..." before the submodule clause,
  and this branch adds the symlink clause after it. Both are kept, in
  that order.
- `_entry_findings`' docstring. 6f45bf9 and 71b58a1 give the order
  "each entry's path, then coverage, then each entry's `leg:`, the two
  mount paths and what the root keeps where the shape writes, then the
  source's own submodules". This branch adds "then each symlink the plan
  keeps in the root that a shape file cannot be written through".
- `_entry_findings`' return line. 6f45bf9 returns
  `unsafe_remediation + remediation`, and this branch appends
  `_root_symlink_findings(plan, source, tree)` to the findings. The
  merge returns both.

Found after the textual merge, and resolved here so that no commit binds
a name twice: 71b58a1 (through 65a9262) imports #198's phrase as
`_CASE_ASIDE` in the "#172's shape half" import block, and b37159a had
imported it on a line of its own. Main's import is kept; this branch's
line and its five-line comment go.

scripts/shape_materialize.py merges with no conflict. `place` gets
71b58a1's `copy_in_the_way` inside its collision block and this branch's
`_refuse_unwritable` after it, as 71b58a1's message foresaw.

Checked on the merged tree:
- `_entry_findings` still hands `_leg_findings` the tree, and the
  symlink findings still come last in its list.
- AST: no top-level name is bound twice in adopt-project.py,
  scaffold-project.py, scripts/repo_shape.py or
  scripts/shape_materialize.py, and none is loaded but bound nowhere.
  Main changed the signature of no function this branch calls.
- Both sides are intact by +/- line sets, apart from the three hunks and
  the import above.

The next two commits fix what this merge breaks, each with a revert
line. #262's rule and this branch's both name a symlink kept AT the
copy under `shape/`. And where the shape's copy lands is now
`copy_in_the_way`'s question, asked before this branch's own check of
it.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
…s state the contract, that a caller writing into a checkout somebody works in calls `force_stage` alone and never after a sweep, instead of counting two callers, so a change to the helper is no longer checked against `update-shape.py` alone with `family.py`'s `add`, `bump` and `remove` commits missing (#369) (#398)

`force_stage`'s docstring said TWO CALLERS, AND ONLY ONE OF THEM SWEEPS
FIRST, and named `update-shape.py apply --add` as the one that calls it
alone; `stage_written`'s closing sentence said the same. Since #242 (PR
#308) `family.py`'s `_commit_one` (every `add`, `bump` and `remove` commit)
and `cmd_bump`'s re-stage call it alone too, for exactly the reason the
docstring gave for `update-shape.py`: they write into a holder somebody may
be working in, where `-A` would stage their work beside the shape's.

Both docstrings now state the contract without a count: a caller that
writes into a checkout somebody works in calls `force_stage` alone, never
after a sweep, and `force_stage` never sweeps itself. The reason the old
text gave is kept, and so is its history: the rule began with
`update-shape.py apply --add` (#202), and `family.py`'s commits took it up
(#242), each stage named exactly. The next alone-caller is bound by the
rule without being named, so it does not make the text stale again. The
opening list of tools that share the helper names `family.py`'s `add`,
`bump` and `remove` commits. `stage_written`'s closing paragraph is
rewritten the same way and drops its own "THREE CALLERS" count.

Docstring-only: the module's AST with its docstrings stripped is identical
before and after.

Closes #369

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 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