diff --git a/AGENTS.md b/AGENTS.md index 9e125f7a..87545e66 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -394,12 +394,12 @@ python3 scripts/family.py siblings --family-root 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 diff --git a/templates/family-root/AGENTS-shape.md b/templates/family-root/AGENTS-shape.md index fbc984d9..ac912591 100644 --- a/templates/family-root/AGENTS-shape.md +++ b/templates/family-root/AGENTS-shape.md @@ -35,13 +35,17 @@ Each writes ONE commit moving the **gitlink** for `members/` 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. @@ -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 (`../`). Two copies of every member is correct and intended — -`members/` 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 (`../`). Two copies of every member is correct and +intended — `members/` 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 diff --git a/templates/family-root/README.md b/templates/family-root/README.md index 7e85df96..6af3d4af 100644 --- a/templates/family-root/README.md +++ b/templates/family-root/README.md @@ -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 diff --git a/tests/test_family_docs_one_spelling.py b/tests/test_family_docs_one_spelling.py index eced3460..e69575a9 100644 --- a/tests/test_family_docs_one_spelling.py +++ b/tests/test_family_docs_one_spelling.py @@ -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 " @@ -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. @@ -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("."))