Skip to content

family.py add, under core.ignorecase=true, commits the member's gitlink as Members/<P> when HEAD holds a path under a case variant of members/ that the index no longer does (a git rm -r Members not yet committed), so .gitmodules and family.yaml say members/<P>, the holder's own validator passes in the working clone, and every fresh clone of the pushed holder leaves the member's directory empty and is refused member-gitlink-absent #363

Closed

Description

@brettheap

Lane: openreposhape-4 (openRepoShape-4)

Found by this lane's adversarial review of #302 (PR #318, head 80050a9; its follow-up F2 and nit N1, which reached this state by following #318's remediation without its commit-first step), and taken further by the re-check at 4302d85 (NIT-B, the three variant shapes). Re-verified on main c7c02fa, on #318's head 0f2a0dd (which carries c7c02fa), and on a scratch merge of 0f2a0dd with #317's head c23e2b0, with local bare remotes, an empty global git config, GIT_ALLOW_PROTOCOL=file and a gh that exits 97 (never called). New here: no refusal text is needed to get into this state. A plain git rm -r Members that has not been committed yet does it. It was simulated on Linux with core.ignorecase=true. It was not run on a case-insensitive disk, and the clone side ran on a case-sensitive disk. What CI does is read from the workflow, not run.

What is wrong. add asks the disk and the index about the mount path (_checked_mount_path; #318 makes the index question any-case) and never asks HEAD. Its commit is a partial commit of three named paths, which git builds from HEAD's tree. With core.ignorecase set, git files the new gitlink under the directory spelling HEAD already has:

U=<openRepoShape checkout>   # a clean checkout at c7c02fa
# Runs from a clean checkout of openRepoShape, in bash, on Linux (GNU coreutils). Nothing leaves the machine: members and
# holder live in local bare repositories under $R, git reads an empty global config, GIT_ALLOW_PROTOCOL=file makes any
# accidental https/ssh clone fail, and a `gh` that exits 97 stands first on PATH.
R=$(mktemp -d); mkdir $R/bin; printf '#!/bin/sh\nexit 97\n' > $R/bin/gh; chmod +x $R/bin/gh; touch $R/gitconfig
export PATH=$R/bin:$PATH GIT_CONFIG_GLOBAL=$R/gitconfig GIT_CONFIG_NOSYSTEM=1 GIT_ALLOW_PROTOCOL=file \
       GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t.invalid GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t.invalid
for P in Atlas Nova Zed; do
  python3 $U/scaffold-project.py --org testorg --project $P --elected-by 'Test Human' --elected-on 2026-10-12 \
      --local-remote-dir $R/remotes --work-dir $R/work >/dev/null
done
python3 $U/scripts/family.py init --org testorg --family Fam --created-by 'Test Human' --created-on 2026-10-12 \
    --local-remote-dir $R/fam --work-dir $R/h >/dev/null 2>&1
H=$R/h/Fam; cd $H
add() { python3 $U/scripts/family.py add --family-root $H --member "$1" --local-remote-dir $R/remotes; }
add testorg/Nova >/dev/null
mkdir Members && echo docs > Members/README.md && git add Members && git commit -qm 'docs under Members/'
git config core.ignorecase true            # what macOS and Windows set
git rm -r -q Members                       # taken out, not committed yet
add testorg/Atlas | grep committed; echo "add: exit ${PIPESTATUS[0]}"
git ls-tree -r HEAD | grep -i 'members/'
git ls-files -s | grep -i members/atlas; git status --porcelain
grep -E 'path = |^    path:' .gitmodules family.yaml | grep -i atlas
python3 scripts/validate-family.py | tail -1; echo "validate in the holder: exit ${PIPESTATUS[0]}"
git clone -q --bare $H $R/pushed.git                  # what a push carries
git -c protocol.file.allow=always clone -q --recurse-submodules $R/pushed.git $R/fresh; echo "recursive clone: exit $?"
echo "files under the clone's Members/Atlas: $(ls -A $R/fresh/Members/Atlas | wc -l)"
cd $R/fresh && python3 scripts/validate-family.py > $R/v.out 2>&1; echo "validate in a fresh clone: exit $?"
head -1 $R/v.out | cut -c1-118

Output at c7c02fa (the merge of #318 and #317 prints the same, commit ids aside; #318's head alone gave the same result for the git rm -r --cached -- Members/Atlas form of this state):

  committed bd3d457efed2: .gitmodules, members/Atlas, family.yaml
add: exit 0
160000 commit 2be24d752a7b6a7c1c70c0b032a72982305869b6	Members/Atlas
100644 blob d8f8d46921aa81abc4c0d27703a8908333ae38c3	Members/README.md
160000 commit dbb3061b5aeef8cd68baeddaf33d58d488e3521a	members/Nova
160000 2be24d752a7b6a7c1c70c0b032a72982305869b6 0	members/Atlas
D  Members/README.md
R  Members/Atlas -> members/Atlas
.gitmodules:	path = members/Atlas
family.yaml:    path: members/Atlas
family ok: Fam (fam), 2 member(s)
validate in the holder: exit 0
recursive clone: exit 0
files under the clone's Members/Atlas: 0
validate in a fresh clone: exit 2
REFUSED member-gitlink-absent: no gitlink (mode 160000) recorded at 'members/Atlas'. Atlas is named in family.yaml but

So the commit add reports records 160000 Members/Atlas, and it keeps Members/README.md too, because the pending deletion is not one of the three paths it names. .gitmodules and family.yaml say members/Atlas. The working clone's index holds members/Atlas (git status: R Members/Atlas -> members/Atlas), so the validator there passes. A fresh clone's index comes from HEAD. There git submodule update --init finds no .gitmodules section for Members/Atlas and skips it with exit 0, leaving the directory empty, and the validator refuses member-gitlink-absent for a member whose row is right there. The holder's CI goes the same way, by reading templates/family-root/.github/workflows/validate.yml: its fetch step's git submodule update --init --recursive exits 0, so members_available is true, the default mode runs, and the pull request is red with a refusal that names the row, not the spelling.

Why. recorded_gitlink reads the index first (#20), which is right for a staged bump, and is why the working clone passes. Pathspecs match case-sensitively whatever core.ignorecase says, which is why the fresh clone cannot find members/Atlas. #318's remediation for a committed variant of the mount path says this outcome in words ("where HEAD holds it too, commit that removal first ... otherwise ... with core.ignorecase set, records the member's gitlink under a spelling other than the members/Atlas that .gitmodules and family.yaml say"). But it is printed only when the index still holds the variant, and the variant here is of members/ itself, under which HEAD holds a file, not the mount path.

What it should do. add never commits the gitlink at a spelling other than members/<P>. The decisions are the author's:

  1. Ask HEAD before the mount. Refuse when HEAD holds an entry at or under a case variant of members/<P>, or of members/, that the index does not, naming the pending change to commit first. family.py: add asks the holder's index for the mount path in any case, so an entry Members/Atlas/README is refused as member-path-taken before anything is cloned instead of leaving the member's 19 files staged under it, and a refused add says the holder is exactly as it was only when the whole index is as it was (#302) #318's commit-first clause becomes a refusal of its own.
  2. Or check the result. After the commit, ask git ls-tree HEAD for a gitlink at exactly members/<P>. When it is anywhere else, undo the commit this command made (HEAD moved only by it) and the mount, and refuse, naming the spelling git chose. This catches any cause, not only the ones foreseen here.
  3. The validator. Whether validate-family.py also reports a gitlink in HEAD under a case variant of a row's path, so that a holder already pushed this way is told why, not just that the gitlink is absent.
  4. Decide with family.py add cannot add any member, under core.ignorecase=true (the macOS and Windows default), to a holder whose index holds a path under a case variant of members/ itself such as Members/README, because git stages the new gitlink as Members/<P> and the commit's members/<P> matches nothing, and the remedy its closing sentence prints fails the same way every time: a policy question for the maintainer, whether add refuses such an index up front, and on which disks #362, the same variant held in the index, where add fails loudly instead.

How to test. The repro as a test in git's index and objects. Commit the variant with update-index --cacheinfo, take it out with git rm --cached, and set core.ignorecase explicitly, so it runs on every OS. Assert either the refusal with a byte-identical holder, or that git ls-tree -r HEAD holds the gitlink at members/<P> and nothing under Members/.

Verified at main c7c02fa, at #318's head 0f2a0dd, and at the scratch merge of 0f2a0dd with #317's head c23e2b0 (scripts/family.py sha256 8339041e…); the finding survives the merge.

Related: #302 and #318 (N1, NIT-B and the commit-first clause), #20 (recorded_gitlink reads the index first), #362 (the index-held twin, a policy question), #364 (the untracked variant on disk).

Activity

  1. added this to the v1.1.0 milestone on Oct 14, 2026
  2. brettheap commented on Oct 14, 2026

    @brettheap
    ContributorAuthor

    v1.1.0 (triage of 2026-10-14): one pull request with #362 and #364, family members under core.ignorecase. Brett Heap ruled on 2026-10-14 that the three stay P2: a Members/ case variant is not counted as an ordinary path.

  3. brettheap commented on Oct 14, 2026

    @brettheap
    ContributorAuthor

    CLAIMED — lane openRepoShape-5, session 50cede29-ab82-4ca3-9f67-d98e1a58c8d2@Eagle, 2026-10-14T22:32:34Z, for #363

    Logged in lanes/log/openRepoShape-5.md at 0336750177eeca882d024dac51338c92605d6f36.

    The three reads (lane-collision-protocol Rule 1):

    1. existing `CLAIMED —` comments on opensoft/openRepoShape#363:
       none
    2. `gh pr list --repo opensoft/openRepoShape --state all --search "#363"`, kept where the row or its body names `#363`:
       none
    3. `git ls-remote --heads git@github.com:opensoft/openRepoShape` branches naming `363` as a whole token:
       none
    
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions