Skip to content
Merged
14 changes: 7 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,13 +405,13 @@ python3 scripts/family.py siblings --family-root <path>
`members/<Project>` inside the holder is pinned and DETACHED, for
`bootstrap` and `validate`; the sibling is where the human works.
**What it never does, and neither do you:** it MOVES NOTHING — when the
parent folder is not named after the family it warns and prints an `mv`,
and running that `mv` yourself relocates a checkout out from under a
shell, an editor and every agent lane, and breaks any linked worktree
(its `.git` file carries an absolute path). An existing clone gets only
`git fetch` — no checkout, no reset, no pull — because somebody works in
there; one of a different repository is reported and skipped, and the
exit is the human's hand, not an overwrite.
parent folder is not named after the family (nor linked as it) it clones
nothing, prints an `mv` and exits 1 (`make`: 2), and running that `mv`
relocates a checkout out from under a shell, an editor and every agent lane,
and breaks any linked worktree (its `.git` file carries an absolute path). An
existing clone gets only `git fetch` — no checkout, no reset, no pull —
because somebody works in there; one of a different repository is reported
and skipped, and the exit is the human's hand, not an overwrite.
6. `update-shape.py` updates a family root exactly as it updates a project,
with the same four refusals, and mirrors into `family.yaml`. An existing
holder receives `scripts/siblings.py` that way: `check` reports it as
Expand Down
113 changes: 79 additions & 34 deletions scripts/family.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@
repository and keeps its own name, and `family.yaml` is what tells the two
apart. `siblings` is the holder's own `make siblings`, run from a checkout of
this standard so it works before the holder is anywhere else; there is ONE
implementation of it, `templates/family-root/scripts/siblings.py`, and this
subcommand runs that file rather than a second copy of its logic.
implementation of it, the holder's pinned `scripts/siblings.py`, and this
subcommand runs that file rather than a second copy of its logic (#543).

EXIT CODES: 0 done, or a `--dry-run` printed what it would do · 1 nothing to
do · 2 a refusal, and a refusal made before the first write is the same
Expand All @@ -60,7 +60,7 @@
branch, or onto no branch at all, as `bump-leg.py` does for a leg; a dry run
commits nothing, so it runs on any branch and says where the real run's commit
would land, or that it would refuse there and the command that makes a branch.
`siblings` exits with the template script's own code, unchanged. A `bump` whose
`siblings` exits with the holder's script's own code, unchanged. A `bump` whose
commit would record nothing — the member is already pinned at `--to`, in the
gitlink and in `family.yaml` alike — is `nothing`: it says `nothing to do`, as
`bump-leg.py` does for a leg, and writes no commit; its dry run says the same
Expand Down Expand Up @@ -122,7 +122,6 @@
import json
import os
import re
import runpy
import shlex
import shutil
import stat
Expand All @@ -143,8 +142,8 @@
from shape_materialize import ( # noqa: E402
FAMILY_RULESET_HINT, SHAPE_REPOSITORY, CommandFailed, check_program,
commit_trailers, env_commit, escape_what_stdout_cannot_encode,
force_stage, materialize_family_root, refuse_bad_branch, run,
stage_written, trailer_line, write_lf,
force_stage, inherited_child_environment, materialize_family_root,
refuse_bad_branch, run, stage_written, trailer_line, write_lf,
)
# What `init` asks of the holder's own reader and validator before it creates
# anything (#307), imported as its own unit and not added to the lists above.
Expand Down Expand Up @@ -884,8 +883,10 @@ def _local_remote_is_empty(bare: Path) -> bool | None:
# ---------------------------------------------------------------------------


SIBLINGS_SCRIPT = (SHAPE_ROOT / "templates" / "family-root" / "scripts" /
"siblings.py")
#: The holder's own copy of the workstation utility, relative to the holder:
#: the path its Makefile's `SIBLINGS_SCRIPT` names, and the file `siblings`
#: runs (#543).
SIBLINGS_SCRIPT = "scripts/siblings.py"


def _family_script() -> str:
Expand Down Expand Up @@ -925,10 +926,10 @@ def _standard_checkout(start: Path) -> Path | None:

A checkout is a directory holding `scripts/family.py` beside
`templates/family-root/`, which is what this file needs of its own
(`SIBLINGS_SCRIPT`, `FAMILY_TEMPLATE`), so it is found whatever the
directory is called and whichever copy of the standard is asked: this
one, another clone, an install. `os.path`'s tests, which answer False
for a directory this user cannot search rather than raising.
(`FAMILY_TEMPLATE`), so it is found whatever the directory is called and
whichever copy of the standard is asked: this one, another clone, an
install. `os.path`'s tests, which answer False for a directory this user
cannot search rather than raising.
"""
for directory in (start, *start.parents):
if os.path.isfile(directory / "scripts" / "family.py") and \
Expand Down Expand Up @@ -1320,8 +1321,24 @@ def _print_next_steps(args, work: Path, folder: Path | None) -> None:
ONE COMMAND A LINE, `cd` apart from `make siblings`: Windows PowerShell
5.1, the shell `setup-project.py` documents, rejects `&&` between two
statements, and these lines are quoted for the shell of the host (#486).

`--work-dir` makes no family folder, and `make siblings` stops before it
clones anything in a holder whose parent folder is not named after the
family (#543), so there the block says so instead of promising clones.
"""
switch = shell_line(["git", "-C", work, "switch", "-c"])
beside = ("`make siblings` then puts each member's WORKING clone\n"
"beside the holder, in the family folder, on its tracking "
"branch.")
where = f"family folder {folder if folder is not None else work.parent}"
if folder is None and work.parent.name != work.name:
# --work-dir MAKES NO FAMILY FOLDER, and `make siblings` clones
# nothing until the holder sits in one (#543), so none is promised.
beside = ("--work-dir made no family folder, so `make siblings`\n"
"clones nothing: it prints the `mv` that puts the holder "
f"in a folder\nnamed {work.name}, and stops until it is "
"there.")
where = f"family folder none: {work.parent} is not named {work.name}"
print(f"""
NEXT STEPS

Expand All @@ -1333,15 +1350,14 @@ def _print_next_steps(args, work: Path, folder: Path | None) -> None:
`add` mounts the member at {MEMBERS_DIR}/<Project>, records its pin, and
writes ONE commit on that branch: it refuses {args.tracking_branch}, the
holder's tracking branch. Push the branch as its NEXT line says and land it
as a pull request. `make siblings` then puts each member's WORKING clone
beside the holder, in the family folder, on its tracking branch.
as a pull request. {beside}

TWO COPIES OF EVERY MEMBER, on purpose: pinned and DETACHED inside the holder
under {MEMBERS_DIR}/ (what `make bootstrap` and `make validate` read), and a
working clone BESIDE the holder — that second one is where you work, and
`family.py bump` is how the family follows it.

family folder {folder if folder is not None else work.parent}
{where}
holder {work}
""")

Expand Down Expand Up @@ -4812,16 +4828,25 @@ def cmd_siblings(args) -> int:

ONE IMPLEMENTATION, TWO ENTRY POINTS (Brett Heap, 2026-09-09, ruling 1b:
*"One implementation, two entry points… No second code path."*). The
implementation is `templates/family-root/scripts/siblings.py`, the file a
holder CARRIES and its Makefile calls; this subcommand runs that same file
implementation is the HOLDER'S OWN `scripts/siblings.py`, the pinned copy
its Makefile's `siblings` target runs; this subcommand runs that same file
against `--family-root`, exactly as the root `bootstrap` shim runs the
assembly root's `scripts/bootstrap.py`. That is what makes it work before
the holder is cloned anywhere else — the standard has the utility, the
holder is only where it usually lives.

`runpy` rather than an import: the script is a `__main__` program that
ends in `sys.exit(main())`, so its exit code is the one this process
exits with, unchanged and unrewritten.
assembly root's `scripts/bootstrap.py`: from a checkout of the standard,
against a holder anywhere on disk.

NOT THE STANDARD'S COPY (#543). It used to run this checkout's
`templates/family-root/scripts/siblings.py`, through `runpy`, so a holder
pinned at another shape ran other code under the two entry points, and
did so inside THIS process, whose `repo_shape` its file then imported in
place of the holder's own. So it runs as `make siblings` runs it: the
holder's file, by this interpreter, in a process of its own, standing in
the holder, with the holder's own `repo_shape.py` and `bootstrap.py`
beside it, and its exit code is the one this subcommand returns,
unchanged.

A HOLDER WITHOUT ONE IS REFUSED BY NAME, as its Makefile refuses (#489,
#503): `siblings-script-missing`, exit 2, before anything is cloned, in
the Makefile's words, which name `update-shape.py` and its `--add`.
"""
root = Path(args.family_root).expanduser().resolve()
if not (root / MANIFEST).is_file():
Expand All @@ -4831,18 +4856,38 @@ def cmd_siblings(args) -> int:
"Remediation: `family.py init --org <org> --family <Name>` creates "
"one; point --family-root at the HOLDER (the `<Family>/<Family>` "
"clone), not at the family folder around it.")
if not SIBLINGS_SCRIPT.is_file():
script = root / SIBLINGS_SCRIPT
if not script.is_file():
raise Refusal(
"siblings-script-missing",
f"{SIBLINGS_SCRIPT} is missing from this checkout of the standard",
"Remediation: this subcommand runs the holder's own copy of that "
"file and has no second implementation to fall back on. Re-clone "
"openRepoShape, or run `make siblings` in the holder, which uses "
"its own copy.")
sys.argv = ["siblings", "--root", str(root),
*(["--dry-run"] if args.dry_run else [])]
runpy.run_path(str(SIBLINGS_SCRIPT), run_name="__main__")
return 0 # pragma: no cover - the script exits before returning here
f"family.py siblings needs {SIBLINGS_SCRIPT}, which this holder "
f"does not have: there is no {script}. It runs the holder's own "
"copy, as `make siblings` does, and has no second implementation "
"to fall back on, so nothing was cloned or fetched.",
"Remediation: update-shape.py copies a changed Makefile but adds "
"a new file only on the human's word, so a holder updated without "
f"--add {SIBLINGS_SCRIPT} lands here. From a checkout of "
"openRepoShape run `"
+ shlex.join(["python3", "update-shape.py", "check", "--root",
str(root)])
+ f"`, which reports {SIBLINGS_SCRIPT} as upstream-added. When "
"the human says to take it, run the apply command it prints with "
f"--add {SIBLINGS_SCRIPT} added. If it reports copy-missing "
"instead, a pinned copy was deleted: restore it from this "
"holder's history rather than with --add.")
# STANDING IN THE HOLDER, AS MAKE STANDS THERE, so every word of the
# command is a constant and none is a value from this command line: the
# holder is the directory it runs in, and `--root .` names it. The child
# writes into this stdout but takes its encoding from its environment,
# not from the `reconfigure` `main()` did (#214): told this stdout's own
# encoding and a handler that cannot raise, it escapes what the stream
# cannot hold, as the in-process run did, instead of dying on an em dash.
sys.stdout.flush()
return subprocess.run(
[sys.executable or PYTHON, SIBLINGS_SCRIPT, "--root", ".",
*(["--dry-run"] if args.dry_run else [])],
cwd=str(root), check=False,
env=inherited_child_environment()).returncode


# ---------------------------------------------------------------------------
Expand Down
10 changes: 6 additions & 4 deletions templates/family-root/AGENTS-shape.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,12 @@ holder (`../<Project>`). Two copies of every member is correct and intended —
`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
it WARNS and prints the exact `mv` for a human to run. 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.** 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 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: 10 additions & 6 deletions templates/family-root/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,9 @@ clones beside it:
```

`make siblings` is what places it: for each row in `family.yaml` it clones the
member at `../<Project>` from the same url `make bootstrap` uses, then runs
that member's own `scripts/bootstrap.py` so its legs are on their tracking
member at `../<Project>` from the same url `make bootstrap` uses, on the
tracking branch the member's pinned `project.yaml` declares, then runs that
member's own `scripts/bootstrap.py` so its legs are on their tracking
branches at their pins. A member that is already there is **fetched and
nothing else** — no checkout, no reset, no pull — because somebody is working
in it. A directory that is a clone of a DIFFERENT repository is reported and
Expand All @@ -63,10 +64,13 @@ named after the family because that is what the folder holds. `family.yaml`
is the only thing that tells the two apart, and it is inside the holder.

**Nothing ever moves a checkout.** When this holder's parent folder is not
named after the family, `make siblings` WARNS and prints the exact `mv` for a
human to run — 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.
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.

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
10 changes: 7 additions & 3 deletions templates/family-root/family.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,13 @@ created_on: "{{CREATED_ON}}"
# is what lets a reader tell why the members need a credential in CI.
visibility: "{{VISIBILITY}}"

# The branch `scripts/bootstrap.py` places each member on, AT its pinned
# commit. `.gitmodules` carries no `branch=`: there is exactly one
# authoritative answer to "what commit of this member is this family".
# The HOLDER's own branch: the one a new holder is created on, and the one
# `scripts/family.py` refuses to commit `add`, `bump` and `remove` on. It
# places no member on any branch: `scripts/bootstrap.py` leaves each member
# DETACHED at its pinned commit, and `make siblings` clones each working copy
# on the `tracking_branch:` of that member's own `project.yaml`. `.gitmodules`
# carries no `branch=`: there is exactly one authoritative answer to "what
# commit of this member is this family".
tracking_branch: "{{TRACKING_BRANCH}}"

# The directory every member is mounted under. One name, so a reader and a
Expand Down
Loading
Loading