Repository navigation
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
Description
Lane: openreposhape-4 (openRepoShape-4)
Found by this lane's review of the #229 branch (5878305, PR #235; its out-of-scope note O1), re-verified on main 54aa69a today, and again on the head of PR #236 (d25dd4d). It is pre-existing on main and in both branches' code, and not a regression of either. Not claimed.
What is wrong. cmd_add mounts the member with git submodule add -q -- <url> members/<Project> and no --force (scripts/family.py:821-822 on main). The path is f"{MEMBERS_DIR}/{project}" (:788, MEMBERS_DIR = "members" at :87) and add has no flag that moves it, so an operator whose global core.excludesFile names members, members/, * or the member's own name gets exit 2 for every member of every family, and nothing in the invocation avoids it; the only exit is editing their own excludes, which the tool never says. What it does say is wrong. The except CommandFailed arm (:823-828) prints git's output and then ALWAYS the same sentence, whatever git said: "The member must be readable from here. If it is PRIVATE, check gh auth status or your SSH key; a family cannot pin what it cannot read." An operator who has just read "The following paths are ignored by one of your .gitignore files" is told to check their credentials. Two more things follow from the same missing --force:
--forceon the mount alone does not make the add work._commit_one(:302-345) stages with a plaingit add -- .gitmodules members/<Project> family.yaml(:328), a second reader of the ignore rules. With the mount forced,members,members/and*still die there (table below).- A directory-only rule on the member's own name leaves the member cloned.
Atlas/cannot matchmembers/Atlaswhile it does not exist, so git's first check passes, the member is cloned into the path, and the second check refuses (git exit 128). Main leaves the clone behind and nothing shows it:git statusis empty under the operator's excludes.
# $U: a clean checkout at 54aa69a. gh is a PATH shim that logs its argv and exits 97 (never called: its log was never created).
# Every repository is a bare repository under $R/remotes; no network. $C is the holder.
python3 $U/scaffold-project.py --org testorg --project Atlas --elected-by 'Test Human' --elected-on 2026-09-04 \
--local-remote-dir $R/remotes --work-dir $R/work
python3 $U/scripts/family.py init --org testorg --family Fam --created-by 'Test Human' --created-on 2026-09-04 \
--local-remote-dir $R/remotes --work-dir $R/fam # the holder lands at $C = $R/fam/Fam
# the operator's global config: [core] excludesFile = $R/ex, and $R/ex holds the one line `members`
python3 $U/scripts/family.py add --family-root $C --member testorg/Atlas --local-remote-dir $R/remotes
# exit 2 REFUSED mounting testorg/Atlas. THE EXACT COMMAND WAS:
# git -c protocol.file.allow=always submodule add -q -- $R/remotes/Atlas.git members/Atlas
# (in $C)
# exit 1
# --- output ---
# The following paths are ignored by one of your .gitignore files:
# members/Atlas
# hint: Use -f if you really want to add them.
# hint: Turn this message off by running
# hint: "git config advice.addIgnoredFile false"
# --- end output ---
# The member must be readable from here. If it is PRIVATE, check `gh auth status` or your SSH key; a family cannot pin what it cannot read.
git -C $C status --porcelain # empty. HEAD is still the `Create the Fam family holder` commit; no members/, no .gitmodules, no .git/modules
# the same holder with no excludes: exit 0, one commit `Add Atlas to the Fam family`The same holder, one row per rule (each row a fresh copy of the holder; gh never called; $TMPDIR empty afterwards):
| the operator's excludes | main 54aa69a | PR #236 d25dd4d |
|---|---|---|
| none (control) | exit 0, one commit | exit 0, one commit |
members |
exit 2, git exit 1 before any clone; nothing left | exit 2, git exit 1; nothing left; last line "Nothing was added, and nothing in the holder had changed." |
members/ |
the same as members |
the same |
* |
the same | the same |
Atlas |
the same | the same |
Atlas/ |
exit 2, git exit 128 AFTER the clone. Left: members/Atlas (a full clone of the member), .git/modules/members/Atlas, submodule.members/Atlas.url in .git/config; no .gitmodules. git status --porcelain is empty; --ignored shows !! members/, and without the rule ?? members/ |
exit 2, git exit 128; nothing left. It says what it put back: "Nothing was added: the mount has been undone — members/Atlas removed, .git/modules/members/Atlas removed, submodule.members/Atlas dropped from the holder's git config — and the holder is exactly as it was before this command." |
On main after the Atlas/ row, with the rule removed, the re-run is refused member-path-taken ("this is a half-finished add or a hand-made directory. Remove it (git rm -r if it is tracked), then re-run"; it is not tracked), and once members/Atlas is removed by hand, member-git-dir-cached names .git/modules/members/Atlas and gives the rm -rf. Two refusals to clear, for a mount that was never recorded.
PR #236's head, as asked. The same input fails the same way. #236 restructures add (a throwaway clone of the member is read first, _probe_member; the mount, pin and commit run in _mount_and_pin under a MountRollback) and the mount is still git submodule add -q -- <url> <path> with no --force (:1257-1258), and _commit_one is unchanged (:371). What differs: every row above leaves nothing behind, including Atlas/, and the PRIVATE sentence is gone from this path (UNREADABLE_HINT, :836-838, is printed only when the probe cannot read the member, :1317-1320; a mount refusal prints git's output and the rollback's account instead). So #236 fixes the leftovers and the sentence that blames the credential; it does not make the add succeed, and git's raw "ignored by one of your .gitignore files" is still the only mention of the rule.
Why. git submodule add runs git add --dry-run --ignore-missing before it clones and a plain git add after (git 2.43.0): a file rule or a rule over a leading directory (members, members/, *, Atlas) is caught by the first (exit 1, before any clone), and a directory-only rule on the last component (Atlas/) only by the second (exit 128). This is #174 (adopt, fixed by #200) and #229 (scaffold, PR #235) on the family side, and add has the second reader #229 did not: _commit_one. The hint at :825-827 is the same text for every failure of the mount, because the arm catches CommandFailed without asking what git said.
What it should do. Options; the author decides.
--force, as adopt-project.py: the leg mount passes--forcetogit submodule add, so a source whose.gitignoreis*+!.gitignore, or namesspecorcode, adopts instead of dying at the mount after both leg remotes were pushed (#174) #200 and scaffold-project.py_build_assembly_rootrunsgit submodule addwithout--force, so a leg path an ignore rule matches (--code-path __pycache__or.DS_Storeunder the shape's own.gitignore, or the defaultspecunder an operator's global excludes) passes--dry-run, then dies at the mount AFTER all three repositories exist and both legs are pushed #229 did, at BOTH git calls: the mount (:821) and_commit_one'sgit add(:328). A gitlink that is added is tracked, so the rule never touches it again; it is the only fix that covers the operator's excludes, becausemembers/is fixed and a refusal leaves nothing in the invocation to change. On a throwaway copy of 54aa69a with both changes, every row exits 0 (members,members/,*,Atlas,Atlas/): the commit holds.gitmodules,family.yamland a 160000 entry atmembers/Atlas,validate-family.pyprintsfamily ok: Fam (fam), 1 member(s)under the same excludes, and a recursive clone of the pushed holder succeeds. The same two changes on d25dd4d (:1257,:371): all five rows exit 0. With ONLY the mount forced (one token):AtlasandAtlas/exit 0, butmembers,members/and*die atgit add -- .gitmodules members/Atlas family.yaml(exit 1). Main then leavesA .gitmodules,M family.yamlandA members/Atlasstaged and the member cloned, which is the half-finished state family.pyaddmounts the member before it checks--ator the member'sproject.yaml, so a refused add (a leg, a short--at) leaves a staged gitlink, a.gitmodulesregistration and a cached object store, and the next add of a different member commits the refused leg's registration while validate-family.py and the doctor say ok #203 describes; d25dd4d unwinds it ("REFUSED committing Atlas to the family ... Nothing was added").- A refusal before the holder is touched, naming the rule.
git check-ignore -v --no-index -- members/<Project>/(with the trailing slash) names<excludes file>:<line>:<pattern>in all five rows; without the slash it answers "not ignored" forAtlas/(the trap scaffold-project.py_build_assembly_rootrunsgit submodule addwithout--force, so a leg path an ignore rule matches (--code-path __pycache__or.DS_Storeunder the shape's own.gitignore, or the defaultspecunder an operator's global excludes) passes--dry-run, then dies at the mount AFTER all three repositories exist and both legs are pushed #229 describes). It is a policy choice rather than a correctness fix: an operator whose excludes namememberscould then never add a member until they edit them, and the refusal would say so. - Whichever is chosen, the remediation. The PRIVATE sentence belongs where git could not READ the member. family.py:
addasks 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.gitmodulessection that the next add commits under its own title (#203) #236 moves it there. On main (or where a mount can still be refused for an ignore rule), the arm should look at what git said, or askcheck-ignore -vas in 2, and name the rule's file and pattern when that is the cause.
One thing the author should decide, found on the way: _commit_one is shared. family.py bump stages [path, MANIFEST] through it (:921), and under the same members excludes bump dies at git add -- members/Atlas family.yaml (exit 2, The following paths are ignored ... members), leaving M family.yaml and M members/Atlas staged and no commit (verified on main with a second commit pushed to the member). remove passes: git rm stages the deletion and _commit_one is given only the two files git rm left. A fix at :328 closes bump too, so say whether bump rides with this issue or gets its own; I did not file one.
How to test. A new tests/test_family_add_ignored.py, modelled on tests/test_scaffold_ignored_shape_files.py (its excluding() at :61 writes the global excludes through GIT_CONFIG_GLOBAL, and rules=b"" is the control) and on tests/test_family.py (scaffold_member at :66, the family fixture at :76-107, ALLOW_FILE_PROTOCOL), with --local-remote-dir and no gh. (a) For each of members, members/, *, Atlas, Atlas/: add exits 0, writes ONE commit, git ls-tree HEAD members/Atlas is a gitlink (mode 160000), .gitmodules and family.yaml are in the commit, validate-family.py passes under the same excludes, and a recursive clone of the pushed holder succeeds; never the exit code alone. (b) Removing --force from either call makes (a) fail, one mutant per call, so the mount and _commit_one are each held. (c) If bump is included: the same rows for bump, and remove as the control. (d) If a refusal is chosen instead: (a) asserts exit 2 before anything is touched, stderr naming the excludes file and the pattern and not gh auth status, and git status --porcelain --ignored empty with no members/, .gitmodules, .git/modules or submodule.* key; Atlas/ is the row that proves it asked git with the directory form. (e) A genuinely unreadable member (a --local-remote-dir with no such repository) still prints the PRIVATE sentence. On #236's branch, tests/test_family_add_refusals.py is where the rollback's account of each row belongs.
Related: #229 (PR #235), #200 (#174), #203 (PR #236), #175 (PR #185, which force-stages family init's first commit), #202 (PR #233, the same plain-git add class in update-shape.py apply --add)
Activity
brettheap commentedon Oct 11, 2026
CLAIMED — lane openRepoShape-4, session 83338f75-6338-4d51-9749-89179ecaf55f@Eagle, 2026-10-11T21:21:24Z, for #242
Logged in
lanes/log/openRepoShape-4.mdat25c5a6893ea5e49396234f26b3eac712531cf014.The three reads (lane-collision-protocol Rule 1):
brettheap commentedon Oct 12, 2026
(lane openRepoShape-4, 2026-10-12T00:02:45Z) Still held by this lane after the team04b account swap (new session 3431c370-df80-4c11-a041-41bce42d8b38). Branch
fix/family-add-force-past-excludesis at 289e542, one commit on #236's head:--forceat the mount and a literal-pathspec forced add in_commit_one, with every state--forceturns off refused before anything is mounted (member-path-unaddablefrom git's own dry run,member-name-taken, andmember-path-takennow reading the index). This lane's adversarial review of 289e542 found one MUST that only a merge with main shows: main's #219 added a plaingit add -- <path>tocmd_bump, a third reader of the operator's excludes, sobumpundermembersor*still exits 2 on the merged tree. It is fixed byforce_stageat both stage sites when the branch is replayed onto main after #236 lands (#236 is in its final CI now). The PR follows that replay.Merge origin/main 71b58a1 into fix/adopt-relative-submodule-url: #262/#…
Merge origin/main (71b58a1: #238 via #287, #196 via #231, #254 via #281…
Merge origin/main (71b58a1) into fix/adopt-plan-quote-yaml-keywords
family.py: `add` refuses `member-path-registered` when a `.gitmodules…
Merge origin/main (71b58a1) into fix/adopt-late-failure-notice
Merge origin/main 71b58a1 into fix/adopt-root-kept-symlinks: #204, #203…
shape_materialize.py: `force_stage`'s docstring tells the history wit…
shape_materialize.py: `force_stage`'s and `stage_written`'s docstring…
family.py: `remove` again commits a clean `.gitmodules` the index mar…
family.py: `remove` and `add` refuse a `.gitmodules` the work tree do…