Skip to content

shape-doctor.py's legs and members NEXT after #354 moves a detached HEAD that holds the only copy of a commit, with no reflog note, in three states: an emptied leg mount whose kept gitdir holds the commit, a holder whose own scripts/bootstrap.py is the NEXT (any commit made in the pinned members/<P>), and a commit in a member's own leg that --recursive moves, so after the paste the commit is held by a reflog entry alone #413

Closed

Description

@brettheap

Lane: openreposhape-4 (openRepoShape-4)

Found by this lane's adversarial review of #276 (PR #354, head c30bfb2, its N1): "the reflog note is not printed for every stranded commit". PR #354's body carries it as follow-up N1 at its head cb2a684. #354 is open. Verified at cb2a684. Case (b) is the same on main 3ae0109, where the holder's bootstrap is already the members NEXT. Cases (a) and (c) come with #354's git line: on main the NEXT for both is a bootstrap line that fails (exit 1 and exit 2) and moves nothing. Local bare remotes, an empty global git config, GIT_ALLOW_PROTOCOL=file, and a gh that exits 97 (only the machine row calls it). Not claimed: macOS and Windows, which were not run.

What is wrong. #354 makes the legs and members NEXT a script-free git submodule update --init --recursive --checkout -- <paths> (submodule_update_next, shape-doctor.py:1801 at cb2a684). That line moves HEAD, so #354 adds a sentence for the case where moving HEAD loses something: "A commit made in a <leg|member> on no branch is left only in its reflog by this line. To keep one, run first git -C branch NAME". The sentence is printed for each mount in off_pin. check_legs (:1874) and members_verdict (:3518) put a mount there only when it is populated, its HEAD is detached, and its HEAD is not the pin while the gitlink is (:1962-1968). Three states move a detached HEAD that holds the only copy of a commit, and none of them is in off_pin:

U=<openRepoShape checkout>   # a clean checkout of PR #354's head cb2a684
# Runs in bash on Linux (GNU coreutils). Nothing leaves the machine: every repository is a local bare one 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
doctor() { python3 $U/shape-doctor.py --root "$1" --json > "$1.json"; }
show() { python3 -c '
import json, sys
r = [r for r in json.load(open(sys.argv[1]))["rows"] if r["id"] == sys.argv[2]][0]
line, _, why = (r["next"] or "").partition("   # ")
print("  " + r["id"] + ":", r["status"], "|", r["reason"])
print("  NEXT:", line); print("  its sentence:", why)
open(sys.argv[1] + ".next", "w").write(line)' "$1" "$2"; }
paste() { (cd $R && sh -c "$(cat $1.json.next)" >/dev/null 2>&1); echo "  pasted: exit $?"; }
kept() { echo "  commit ${2:0:12}: HEAD now $(git -C "$1" rev-parse --short=12 HEAD), refs that hold it: $(git -C "$1" for-each-ref --contains $2 | wc -l), reflog entries: $(git -C "$1" reflog --format=%H | grep -c $2)"; }
python3 $U/scaffold-project.py --org testorg --project Atlas --elected-by 'Test Human' --elected-on 2026-10-12 \
    --local-remote-dir $R/remotes --work-dir $R/work >/dev/null
echo "== (a) a project: a commit made on a leg's detached HEAD, then the mount emptied (its gitdir kept)"
C=$R/a; git clone -q --recurse-submodules $R/remotes/Atlas.git $C
(cd $C/spec && echo x >> README.md && git commit -qam 'work on a detached HEAD'); K=$(git -C $C/spec rev-parse HEAD)
git -C $C submodule deinit -q -f spec
doctor $C; show $C.json legs; paste $C; kept $C/spec $K
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
python3 $U/scripts/family.py add --family-root $R/h/Fam --member testorg/Atlas --local-remote-dir $R/remotes >/dev/null
git -C $R/h/Fam push -q origin HEAD:main
echo "== (b) a holder that has its own scripts/bootstrap.py: a commit made in the pinned (detached) member"
H=$R/b; git clone -q --recurse-submodules $R/fam/Fam.git $H
(cd $H/members/Atlas && echo y > NOTES.md && git add NOTES.md && git commit -qm 'work in the pinned copy'); K=$(git -C $H/members/Atlas rev-parse HEAD)
doctor $H; show $H.json members; paste $H; kept $H/members/Atlas $K
echo "== (c) a holder without one: commits in the member and in the member's own (detached) spec leg"
H=$R/c; git clone -q --recurse-submodules $R/fam/Fam.git $H
git -C $H rm -q scripts/bootstrap.py && git -C $H commit -qm 'no bootstrap'
git -C $H submodule update -q --init --recursive
(cd $H/members/Atlas && echo y > NOTES.md && git add NOTES.md && git commit -qm 'work in the member'); M=$(git -C $H/members/Atlas rev-parse HEAD)
(cd $H/members/Atlas/spec && echo z >> README.md && git commit -qam 'work in its leg'); K=$(git -C $H/members/Atlas/spec rev-parse HEAD)
doctor $H; show $H.json members; paste $H; kept $H/members/Atlas $M; kept $H/members/Atlas/spec $K

Output at cb2a684:

== (a) a project: a commit made on a leg's detached HEAD, then the mount emptied (its gitdir kept)
  legs: FINDING | spec: the gitlink is there and the directory is empty -- an unbootstrapped clone
  NEXT: git -C $R/a submodule update --init --recursive --checkout -- spec
  its sentence: checks each leg out at the commit this repository records for it, and fetches a mount that is empty. Then make bootstrap puts each leg on its tracking branch, and leaves alone one whose branch is already ahead of the pin
  pasted: exit 0
  commit 037c3d06add1: HEAD now 739634162782, refs that hold it: 0, reflog entries: 1
== (b) a holder that has its own scripts/bootstrap.py: a commit made in the pinned (detached) member
  members: FINDING | members/Atlas: checked out at c947e34d5a4a, pinned at 8c60cb1e957f
  NEXT: python3 $R/b/scripts/bootstrap.py --root $R/b
  its sentence: what `make bootstrap` runs in a holder: it fetches every member at its pin and leaves it detached there
  pasted: exit 0
  commit c947e34d5a4a: HEAD now 8c60cb1e957f, refs that hold it: 0, reflog entries: 1
== (c) a holder without one: commits in the member and in the member's own (detached) spec leg
  members: FINDING | members/Atlas: checked out at 92997d61c9c3, pinned at 8c60cb1e957f
  NEXT: git -C $R/c submodule update --init --recursive --checkout -- members/Atlas
  its sentence: checks each member out at the commit this repository records for it, and fetches a mount that is empty. A commit made in a member on no branch is left only in its reflog by this line. To keep one, run first git -C $R/c/members/Atlas branch NAME with a name of your choice. This holder has no scripts/bootstrap.py that can run, so this is the plain git line, with no credential rewrite for a private member
  pasted: exit 0
  commit 92997d61c9c3: HEAD now 8c60cb1e957f, refs that hold it: 0, reflog entries: 1
  commit 519bd430d34e: HEAD now 739634162782, refs that hold it: 0, reflog entries: 1

In (a), in (b), and for the second commit in (c), the commit is now held by no ref, only by a reflog entry that git gc eventually expires, and the sentence said nothing about it. The first commit in (c) is the one the sentence does name; the repro does not run its keep line. How each state escapes off_pin:

  • (a) The mount is empty, so check_legs reads no HEAD (populated is false). But .git/modules/spec keeps its own HEAD, the detached commit, and submodule update resets it to the gitlink.
  • (b) Members are detached by design, so any commit made in members/<P> is on no branch. The holder's own scripts/bootstrap.py is the NEXT whenever it can run (:3593). Its sentence says only that it "fetches every member at its pin and leaves it detached there". off_pin is computed for this member and not used on this branch.
  • (c) --recursive goes into the member's own legs and checks each out at the gitlink the MEMBER records. The sentence names members/Atlas and its keep line, and says nothing of members/Atlas/spec, whose commit goes the same way.

Why. off_pin answers "is this mount's HEAD detached off its pin, as this row reads it", which is a narrower question than "does pasting this line move a detached HEAD that holds a commit". The line reaches gitdirs whose mount is empty, and nested submodules the row never reads. The bootstrap branch was kept from main (R276-3) and did not get the sentence.

What it should do. Wherever the NEXT moves a detached HEAD that holds a commit no ref holds, the sentence names it and how to keep it. The decisions are the author's:

  1. (b) Print the sentence on the bootstrap branch too. off_pin is already there.
  2. (a) Read the kept gitdir's HEAD for an empty mount and compare it with the gitlink. The keep line cannot be git -C <mount> branch NAME here: the mount is empty, so git walks up and would branch the ROOT. It has to name the gitdir (git --git-dir=<root>/.git/modules/<path> branch NAME, say).
  3. (c) Either look into each populated member's own legs before printing the line, or say in general words that --recursive moves the member's own legs too, or take --recursive off the members line. That last would cost the holder's bootstrap its parity (fetch_members recurses).
  4. Whether this lands before or after shape-doctor.py: fifteen NEXT builders end the command with # <comment>, which a stock interactive zsh (macOS's default shell) does not read as a comment, so pasting the line hands the comment's words to the command as arguments, runs whatever follows a ;, and runs the make bootstrap the legs and members comments put in backticks, then exits 127 #277 moves the sentence into why.

How to test. The three states in tests/test_doctor_legs_members_next.py. In each, the commit is named before the paste, and the named keep line, run first, leaves it on a branch after the paste. That is the existing …keep_line…keeps_a_detached_commit test's shape.

Verified at PR #354's head cb2a684 (shape-doctor.py sha256 82a5ac78…), and case (b) at main 3ae0109 (f1731ff4…).

Related: #276 / PR #354, #277 (the sentence moves to why; in progress in this lane), #414, #415.

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): #414 can ride along (backlog); it is the same check_legs and members routing and submodule_update_next. Brett Heap ruled on 2026-10-14 that case (b) stays P2: AGENTS.md:427 forbids its precondition.

  3. brettheap commented on Oct 14, 2026

    @brettheap
    ContributorAuthor

    CLAIMED — lane openRepoShape-4, session 11c6c0ef-495a-4a05-94cf-811b7b0a1012@Eagle, 2026-10-14T16:52:44Z, for #413

    Logged in lanes/log/openRepoShape-4.md at a33ad5323f7d4e5b22f1cd3d7a90d60c6b87fc7f.

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

    1. existing `CLAIMED —` comments on opensoft/openRepoShape#413:
       none
    2. `gh pr list --repo opensoft/openRepoShape --state all --search "#413"`, kept where the row or its body names `#413`:
       none
    3. `git ls-remote --heads git@github.com:opensoft/openRepoShape` branches naming `413` 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