Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
c2f2884
validate-family.py and family.py: a row's gitlink that no section of …
Oct 15, 2026
6f938ed
tests/test_family_head_lost_gitmodules.py: the rows `member-gitmodule…
Oct 15, 2026
6aace42
family.py and validate-family.py: `remove` refuses a holder with no `…
Oct 16, 2026
08b6245
Merge main 95780dd0 (#637, #607) into fix/611-validate-family-head-gi…
Oct 16, 2026
d660ec5
bootstrap.py and validate-family.py: a member no credential would che…
Oct 16, 2026
852c5c4
Merge #639's head 08b6245b (main 95780dd0, #637, #607) into fix/648-b…
Oct 16, 2026
159a08f
Merge branch 'main' into fix/611-validate-family-head-gitmodules
brettheap Oct 16, 2026
431aaf3
Merge #639's final head 159a08f0 (main 24769920 merged into it) into …
Oct 16, 2026
8b39cd1
Merge main c6122e8e, #639's squash, into fix/648-bootstrap-and-gate-o…
Oct 16, 2026
73fa3ff
README.md, the handbook, AGENTS.md and the family-root docs: `init`'s…
Oct 16, 2026
d91fd15
validate-family.py and validate-pins.py: a gitlink removal staged and…
Oct 16, 2026
bad6c00
Merge main 2fac9790 into fix/648-bootstrap-and-gate-order: #634's per…
Oct 16, 2026
c42286d
README.md and the handbook: for a holder straight in a folder the mov…
Oct 16, 2026
1a69505
bootstrap.py: a plain clone's update that dies at a gitlink no sectio…
Oct 16, 2026
dbca113
validate-family.py: after `git rm --cached` of a hand mount the stage…
Oct 16, 2026
45c512e
tests/test_doctor_legs_members_next.py: the bootstrap that cures noth…
Oct 16, 2026
031ba75
Merge #682's head 45c512eb into fix/681-validate-family-remedies: `_c…
Oct 16, 2026
23e1105
bootstrap.py: `.gitmodules` is read as git's own parser reads it, a m…
Oct 17, 2026
03e0b5a
Merge #682's second fix round 23e11056 (fix/648-bootstrap-and-gate-or…
Oct 17, 2026
f74f640
bootstrap.py: a `.gitmodules` a merge left unmerged in the index is i…
Oct 17, 2026
58d6c50
Merge #682's third fix round f74f640c (fix/648-bootstrap-and-gate-ord…
Oct 17, 2026
1da948f
Merge lane 5's #738 head c42286de (fix/695-family-docs) as the stacke…
Oct 17, 2026
4e199e4
Merge lane 5's #747 head 58d6c50f (fix/681-validate-family-remedies) …
Oct 17, 2026
5afb24b
Merge branch 'main' into fix/648-bootstrap-and-gate-order
brettheap Oct 17, 2026
1423e5a
family-root README.md and AGENTS-shape.md, and AGENTS.md: the holder'…
Oct 17, 2026
17105a3
Merge #682's final head 5afb24b9 (landed as dc7d3fb)
Oct 17, 2026
ff7c59b
Merge main dc7d3fbb.. after #682 landed as dc7d3fb
Oct 17, 2026
c228e80
Merge lane 5's #747 head ff7c59b8 (fix/681-validate-family-remedies) …
Oct 17, 2026
0e1e0a8
family-root AGENTS-shape.md and tests/test_family_docs_one_spelling.p…
Oct 17, 2026
ea86cb6
tests/test_family_docs_one_spelling.py: the folder rule is read with …
Oct 17, 2026
75e35f1
Merge origin/main (24a977c0) into fix/695-family-docs: the handbook's…
Oct 17, 2026
0b26ace
Merge origin/main (2e476a77) into fix/695-family-docs: #630 and this …
Oct 17, 2026
666d027
Merge lane 5's #747 squash 6626a3c5 (its final head ff7c59b8 is merge…
Oct 18, 2026
9b38816
Merge lane 5's #738 final head 0b26aced (fix/695-family-docs), landed…
Oct 18, 2026
859b8af
Merge lane 5's #738 squash e62b5e17 (its final head 0b26aced is merge…
Oct 18, 2026
c4f1c5b
Merge origin/main (7b210593) into fix/763-holder-docs-agree
Oct 18, 2026
bfb1cd9
Merge origin/main (b9b70692) into fix/763-holder-docs-agree
Oct 18, 2026
ee2a664
Merge branch 'main' into fix/763-holder-docs-agree
brettheap Oct 18, 2026
0519f9f
Merge branch 'main' into fix/763-holder-docs-agree
brettheap Oct 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -394,12 +394,12 @@ python3 scripts/family.py siblings --family-root <path>
A holder keeps `members/` in ONE spelling on every disk: `add` refuses
`family-members-dir-variant` (`member-path-taken` for a variant of the
member's own path) until a human renames what sits under another spelling.
3. **Each of `add`, `bump` and `remove` writes ONE commit** moving the
gitlink and the pin together, on a branch you make first (see above): the
tracking branch is refused (`family-on-tracking-branch`). Push the branch
as its `NEXT` line says and land it as a pull request; never suggest a
push to the default branch. Never hand-edit `family.yaml`'s `members:`
block — the tool rewrites it wholesale.
3. **Each of `add`, `bump` and `remove` writes ONE commit** moving the gitlink
and the pin together, on a branch made first (`family-on-tracking-branch`
refuses the tracking branch). Push it as its `NEXT` line says and land it as
a pull request; never suggest a push to the default branch. Never hand-edit
`family.yaml`'s `members:` block, which the tool rewrites wholesale, but for
a row repair `validate-family.py` names; the commit is then the person's.
4. **The member must be scaffolded or adopted FIRST.** A family cannot make a
project out of a repository; it can only pin one that already is.
5. **`make siblings` in the holder — or `family.py siblings --family-root
Expand Down
39 changes: 22 additions & 17 deletions templates/family-root/AGENTS-shape.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,17 @@ Each writes ONE commit moving the **gitlink** for `members/<Project>` and that
row's **`members[].pin`** in `family.yaml` together. `make pins`
(`scripts/validate-family.py`) refuses when the two disagree, and it is right
to: a gitlink moved alone is a pin that names a commit the tree is not at. Do
not hand-edit the `members:` block — the tool rewrites it wholesale. Land the
commit as a pull request: each refuses this holder's tracking branch
(`family-on-tracking-branch`) and names the `git switch -c` that makes a
branch, as the block's first line does, and ends by printing that branch's
`git push`. The member must already be scaffolded or adopted, because a family
cannot make a project out of a repository. A holder keeps `members/` in ONE
spelling on every disk: `add` refuses `family-members-dir-variant`
not hand-edit the `members:` block — the tool rewrites it wholesale — but for a
row repair that `validate-family.py` names in a holder already in a repair
state: put back, as git last had it, a row deleted or whose `path:` was edited
by hand (`git log -p -- family.yaml` shows the change), or take out the row of
a member that is to go (`member-gitlink-absent`); the commit is then the
person's. Land the commit as a pull request: each refuses this holder's
tracking branch (`family-on-tracking-branch`) and names the `git switch -c`
that makes a branch, as the block's first line does, and ends by printing that
branch's `git push`. The member must already be scaffolded or adopted, because
a family cannot make a project out of a repository. A holder keeps `members/`
in ONE spelling on every disk: `add` refuses `family-members-dir-variant`
(`member-path-taken` for a variant of the member's own path) until a human
renames what sits under another spelling.

Expand Down Expand Up @@ -89,19 +93,20 @@ the estate is parked because the command finished. **Nothing under a member's
`.git` file carries an absolute path, which is machine plumbing and not the
family's business.

`make siblings` places the WORKSTATION layout: a plain folder named after the
family, this holder inside it, and each member's working clone beside the
holder (`../<Project>`). Two copies of every member is correct and intended —
`members/<Project>` here is pinned and DETACHED, for `bootstrap` and
`make siblings` places the WORKSTATION layout: a plain folder named after this
holder's repository, this holder inside it, and each member's working clone
beside the holder (`../<Project>`). Two copies of every member is correct and
intended — `members/<Project>` here is pinned and DETACHED, for `bootstrap` and
`validate`; the sibling is where a human works, and `family.py bump` is how
this family follows it. What it will NOT do, and you must not do either:

* **It moves nothing.** When the parent folder is not named after the family,
and no link of that name beside it leads to it, it WARNS, prints the exact
`mv` for a human to run and stops, exit 1 (`make`: 2), before it clones
anything. Do not run that `mv` on your own initiative: relocating a checkout
breaks an open shell, an editor and any LINKED WORKTREE, whose `.git` file
carries an absolute path.
* **It moves nothing.** This holder's parent folder must be named EXACTLY after
its repository, case included, on every disk. When it is not, and no link of
that name beside it leads to it, it WARNS, prints the exact `mv` for a human
to run and stops, exit 1 (`make`: 2), before it clones anything. Do not run
that `mv` on your own initiative: relocating a checkout breaks an open shell,
an editor and any LINKED WORKTREE, whose `.git` file carries an absolute
path.
* **It never touches an existing clone beyond `git fetch`.** No checkout, no
reset, no pull, no stash, no branch switch — somebody is working in there.
A directory that is a clone of a different repository is reported and
Expand Down
16 changes: 8 additions & 8 deletions templates/family-root/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,14 @@ that is the name `make siblings` checks the folder against. The display name
in `family.yaml` is only a title and names no folder, even when it is spelled
differently.

**Nothing ever moves a checkout.** When this holder's parent folder is not
named after the family, and no link of that name beside it leads to it,
`make siblings` WARNS, prints the exact `mv` for a human to run and stops
before it clones anything, so that the run after the move clones each member
once — it moves nothing itself. Relocating a
directory out from under a shell, an editor or an agent lane breaks linked
worktrees, whose `.git` files carry absolute paths, and this standard does
not act by surprise.
**Nothing ever moves a checkout.** This holder's parent folder must be named
EXACTLY after its repository, case included, on every disk. When it is not, and
no link of that name beside it leads to it, `make siblings` WARNS, prints the
exact `mv` for a human to run and stops before it clones anything, so that the
run after the move clones each member once — it moves nothing itself.
Relocating a directory out from under a shell, an editor or an agent lane
breaks linked worktrees, whose `.git` files carry absolute paths, and this
standard does not act by surprise.

From a checkout of `{{SHAPE_REPOSITORY}}` the same utility runs against a
holder anywhere on disk, which is what to use before this repository is
Expand Down
168 changes: 167 additions & 1 deletion tests/test_family_docs_one_spelling.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,42 @@
The printed move itself is pinned by `test_family_siblings_placement.py`
(`~/projects/InkRouter` becomes `~/InkRouter/InkRouter`): this file holds the
sentences that describe it.

THE HOLDER'S OWN DOCS (#763). The holder's README and its pinned
`AGENTS-shape.md` said the parent folder was "not named after the family",
which reads as a match that ignores case, where `siblings.py` compares the
name exactly and has a separate branch for a folder that differs only in
case. Both now say the standard's words: named EXACTLY after its repository,
case included, on every disk.

THE HAND REPAIR OF A ROW (#763). `AGENTS.md` and the holder's
`AGENTS-shape.md` say never to hand-edit `family.yaml`'s `members:` block,
and `validate-family.py` has two remedies that tell a person to: `RESTORE_ROW`
(a row deleted, or its `path:` edited, by hand is put back as git last had
it) and `member-gitlink-absent`'s (the row of a member that is to go is taken
out by hand). The never is true of the normal path, where `add`, `bump` and
`remove` rewrite the block; the remedies are for a holder already in a repair
state. So each doc keeps its never and names the validator's repair beside
it, and says whose commit that is; the shape doc, which has the room, names
both cases. Each passage is held WHOLE (`PASSAGES`), as `ONE_SPELLING` is: the
words a never needs to be an exception, "but for a row repair", are the ones a
one-word edit turns back into the contradiction. The validator's remedies are
read from the validator, `member-gitlink-absent` as the refusal it raises, so
a remedy that stops telling the person to edit a row fails here and takes the
docs' exception with it, and so does a copy of `RESTORE_ROW`'s words that no
longer says what the constant does.
"""

from __future__ import annotations

import ast
import functools
import html
import re

import pytest

from conftest import REPO
from conftest import REPO, load_without_bytecode

ONE_SPELLING = (
"A holder keeps `members/` in ONE spelling on every disk: `add` refuses "
Expand Down Expand Up @@ -72,6 +97,66 @@
"templates/family-root/AGENTS-shape.md"]
README_AND_HANDBOOK = [README, HANDBOOK]

HOLDER_README = "templates/family-root/README.md"
SHAPE_DOC = "templates/family-root/AGENTS-shape.md"
HOLDER_DOCS = [HOLDER_README, SHAPE_DOC]

#: How the shape doc's layout paragraph names the folder (#763): after the
#: holder's repository, which the holder's README says at `:66-70` is what
#: `{{FAMILY}}` is, and not "the family", which `--name` makes a display name.
SHAPE_LAYOUT = "a plain folder named after this holder's repository"

#: What the holder's docs said of the folder before #763: a match that reads
#: as case-insensitive, where `siblings.py` compares the name exactly.
BEFORE_763 = "not named after the family"

#: The folder rule's owner, its subject and its "must", with the words every
#: place says it in: the standard's README and the handbook, and the holder's
#: README and `AGENTS-shape.md` (#763). "named EXACTLY ..." alone would pass a
#: doc that said the folder "need not be" named so, and "parent folder must be"
#: one that gave it to a member's.
FOLDER_RULE = "holder's parent folder must be " + NAMED_EXACTLY

#: Each doc's whole passage on hand-editing `members:`, from its never to
#: whose commit a repair is (#763). A WHOLE SENTENCE, as `ONE_SPELLING` is: the
#: never is an exception, for a row repair `validate-family.py` names, and a
#: doc that kept every word of it but turned the "but for" into "not even for",
#: or the repair into another thing, would say what the validator's remedies
#: say not to do. `AGENTS.md` is at its line cap, so its passage is the
#: shorter; the shape doc names both of the validator's cases.
PASSAGES = {
"AGENTS.md": (
"Never hand-edit `family.yaml`'s `members:` block, which the tool "
"rewrites wholesale, but for a row repair `validate-family.py` "
"names; the commit is then the person's."),
SHAPE_DOC: (
"Do not hand-edit the `members:` block — the tool rewrites it "
"wholesale — but for a row repair that `validate-family.py` names "
"in a holder already in a repair state: put back, as git last had "
"it, a row deleted or whose `path:` was edited by hand (`git log -p "
"-- family.yaml` shows the change), or take out the row of a member "
"that is to go (`member-gitlink-absent`); the commit is then the "
"person's."),
}

#: The validator's two remedies that tell a person to edit a row by hand, and
#: the words of each that the shape doc takes its two cases from. `RESTORE_ROW`
#: is printed by `member-gitlink-unnamed`; the other is the remedy of
#: `member-gitlink-absent` (#681), whose code the shape doc names.
RESTORE_ROW_CASE = "as git last had it"
ROW_OUT_CASE = "is to go"
GITLINK_ABSENT = "member-gitlink-absent"
VALIDATOR = (REPO / "templates" / "family-root" / "scripts" /
"validate-family.py")

#: The sentence of each remedy that tells a person to edit a row by hand, as a
#: pattern. `member-gitlink-absent`'s remedy says "by hand" of a `.gitmodules`
#: section too, so the phrase alone would not show that the row's is there.
REMEDY_SENTENCES = {
"RESTORE_ROW": r"edited by hand, put the row back as git last had it",
GITLINK_ABSENT: r"If it is to go, take its row out of \S+ by hand",
}


def shown(path: str) -> str:
"""The text a reader sees in `path`, on one line.
Expand Down Expand Up @@ -125,3 +210,84 @@ def test_the_folder_is_named_exactly_after_the_repository(path):
@pytest.mark.parametrize("path", README_AND_HANDBOOK)
def test_the_move_for_a_holder_straight_in_a_folder_is_one_level_up(path):
assert STRAIGHT_IN_A_FOLDER in shown(path)


@pytest.mark.parametrize("path", README_AND_HANDBOOK + HOLDER_DOCS)
def test_the_four_places_say_the_folder_rule_in_the_same_words(path):
assert FOLDER_RULE in shown(path)


@pytest.mark.parametrize("path", HOLDER_DOCS)
def test_the_holders_own_docs_no_longer_say_not_named_after_the_family(path):
assert BEFORE_763 not in shown(path)


def test_the_shape_docs_layout_names_the_folder_after_the_repository():
assert SHAPE_LAYOUT in shown(SHAPE_DOC)


@functools.cache
def validator():
"""`validate-family.py` as a holder carries it, loaded with no bytecode."""
return load_without_bytecode(VALIDATOR, "validate_family_hand_repair_docs")


@pytest.mark.parametrize("path", PASSAGES)
def test_each_doc_says_its_hand_edit_passage_whole(path):
assert PASSAGES[path] in shown(path)


@pytest.mark.parametrize("case", [RESTORE_ROW_CASE, ROW_OUT_CASE,
GITLINK_ABSENT])
def test_the_shape_doc_passage_names_both_of_the_validators_cases(case):
assert case in PASSAGES[SHAPE_DOC]


@pytest.fixture
def refusal(tmp_path, monkeypatch):
"""The refusal the validator raises for a row whose gitlink is not there,
through `_check_member_gitlink`, which gives it its code and its remedy
(no repository is needed: HEAD is then unlistable, `recorded_gitlink`
finds none and the remedy writes the `.gitmodules` section)."""
monkeypatch.setenv("GIT_CEILING_DIRECTORIES", str(tmp_path.parent))
module = validator()
with pytest.raises(module.Refusal) as caught:
module._check_member_gitlink(tmp_path, "members/IRQS", "0" * 40,
"IRQS", "acme/IRQS", {}, None, "rule")
return caught.value


def test_member_gitlink_absent_is_raised_telling_the_person_to_edit_the_row(
refusal):
assert refusal.code == GITLINK_ABSENT
text = " ".join(str(refusal).split())
assert re.search(REMEDY_SENTENCES[GITLINK_ABSENT], text), (
f"{GITLINK_ABSENT} no longer tells a person to edit a row by hand: "
"the docs' exception for the validator's repair goes with it")
assert ROW_OUT_CASE in text


def test_restore_row_still_tells_the_person_to_edit_a_row_by_hand():
text = " ".join(validator().RESTORE_ROW.split())
assert re.search(REMEDY_SENTENCES["RESTORE_ROW"], text), (
"RESTORE_ROW no longer tells a person to edit a row by hand: the "
"docs' exception for the validator's repair goes with it")
assert RESTORE_ROW_CASE in text


def test_every_inline_copy_of_restore_rows_words_is_what_the_constant_says():
"""`gitmodules-path-unnamed` spells the first words of `RESTORE_ROW` out in
its own text, not through the constant, so a change to one that missed the
other would send a person two ways. Each string the validator holds that
begins as `RESTORE_ROW` does is read against it."""
source = VALIDATOR.read_text(encoding="utf-8")
constant = " ".join(validator().RESTORE_ROW.split())
opening = constant[:len("If its row was deleted")]
copies = [" ".join(node.value.split())
for node in ast.walk(ast.parse(source))
if isinstance(node, ast.Constant)
and isinstance(node.value, str)
and node.value.strip().startswith(opening)]
assert len(copies) >= 2, "the constant and its inline copy"
for copy in copies:
assert constant.startswith(copy.rstrip("."))
Loading