From 5d5973e5f38c73d5bfc27b091616ff01deb31ae5 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 02:30:05 +0000 Subject: [PATCH 1/3] park, resume and status find a family folder named for its repository or its display name, and resume reads the holder's repository from family.yaml where it is on disk (#236) is_family_folder took as a family folder only when family.yaml's name: equalled the folder. openRepoShape v1.1.0 names the folder, and the holder in it, after the holder's REPOSITORY, the last part of repository: (opensoft/openRepoShape#629), and make siblings stops before cloning when the folder is not that name (opensoft/openRepoShape#543). A family whose display name: differs from its repository name had no layout that worked for both. The folder now matches when it equals name: (estates laid out before v1.1.0) or the last part of repository: (v1.1.0). The shared resolver stays byte for byte the same in all three commands. resume's holder repository comes from the org the record is filed under and the family folder; the record never carries the repository itself. Where a holder is already on disk, its family.yaml repository: is read instead. Co-Authored-By: Claude Sonnet 5.5 --- park | 28 +++- resume | 69 ++++++-- status | 28 +++- tests/test_park_resume_commands.py | 243 +++++++++++++++++++++++++++++ tests/test_status_command.py | 58 +++++++ 5 files changed, 397 insertions(+), 29 deletions(-) diff --git a/park b/park index 132ce6db..81048b50 100755 --- a/park +++ b/park @@ -130,7 +130,8 @@ USAGE # first time somebody copied only one of them. The rules themselves are NOT # new: `//family.yaml` carrying `kind: family-manifest` and naming # `` is `setup-project.py`'s `family_folder_here`, doubled-name layout -# and all (#76, RULING ruling 3). +# and all (#76, RULING ruling 3). What "naming" accepts is widened by #236, +# at `is_family_folder` below: the display `name:` or the repository's name. # `$PROJECTS_DIR` names ONE directory when it is set. Otherwise both # spellings, in that order, because people really do use both - and DEDUPED by @@ -251,11 +252,19 @@ is_pinned_member() { [ "$YAML_VALUE" = "family-manifest" ] } -# Is `` a FAMILY FOLDER? All three facts have to agree, exactly as -# `family_folder_here` requires them to: the holder clone sits at -# `/`, its `family.yaml` says `kind: family-manifest`, and it -# names that same family. A directory called Northwind is a directory called -# Northwind; what makes it a family folder is read, never guessed from a name. +# Is `` a FAMILY FOLDER? All three facts have to agree: the holder clone +# sits at `/`, its `family.yaml` says `kind: family-manifest`, +# and the folder is named for the family. A family has TWO names and either +# one is the folder's (#236): its display `name:`, which is the rule this +# block began with and what an estate laid out before openRepoShape v1.1.0 +# has, or the LAST PART of its `repository:`, which is v1.1.0's rule - the +# holder, and the folder around it, are named for the REPOSITORY, `name:` +# being only a title (opensoft/openRepoShape#629), and the holder's `make +# siblings` stops before cloning when the folder is not that name +# (opensoft/openRepoShape#543). A family whose two names differ has no layout +# that works for both unless both are accepted. A directory called Northwind +# is a directory called Northwind; what makes it a family folder is read, +# never guessed from a name. is_family_folder() { local dir="$1" name manifest name="${dir##*/}" @@ -263,8 +272,11 @@ is_family_folder() { manifest="$dir/$name/family.yaml" yaml_scalar "$manifest" kind || return 1 [ "$YAML_VALUE" = "family-manifest" ] || return 1 - yaml_scalar "$manifest" name || return 1 - [ "$YAML_VALUE" = "$name" ] + if yaml_scalar "$manifest" name && [ "$YAML_VALUE" = "$name" ]; then + return 0 + fi + yaml_scalar "$manifest" repository || return 1 + [ "${YAML_VALUE##*/}" = "$name" ] } is_project_root() { diff --git a/resume b/resume index 92fd4303..b3d2a967 100755 --- a/resume +++ b/resume @@ -113,7 +113,8 @@ USAGE # first time somebody copied only one of them. The rules themselves are NOT # new: `//family.yaml` carrying `kind: family-manifest` and naming # `` is `setup-project.py`'s `family_folder_here`, doubled-name layout -# and all (#76, RULING ruling 3). +# and all (#76, RULING ruling 3). What "naming" accepts is widened by #236, +# at `is_family_folder` below: the display `name:` or the repository's name. # `$PROJECTS_DIR` names ONE directory when it is set. Otherwise both # spellings, in that order, because people really do use both - and DEDUPED by @@ -234,11 +235,19 @@ is_pinned_member() { [ "$YAML_VALUE" = "family-manifest" ] } -# Is `` a FAMILY FOLDER? All three facts have to agree, exactly as -# `family_folder_here` requires them to: the holder clone sits at -# `/`, its `family.yaml` says `kind: family-manifest`, and it -# names that same family. A directory called Northwind is a directory called -# Northwind; what makes it a family folder is read, never guessed from a name. +# Is `` a FAMILY FOLDER? All three facts have to agree: the holder clone +# sits at `/`, its `family.yaml` says `kind: family-manifest`, +# and the folder is named for the family. A family has TWO names and either +# one is the folder's (#236): its display `name:`, which is the rule this +# block began with and what an estate laid out before openRepoShape v1.1.0 +# has, or the LAST PART of its `repository:`, which is v1.1.0's rule - the +# holder, and the folder around it, are named for the REPOSITORY, `name:` +# being only a title (opensoft/openRepoShape#629), and the holder's `make +# siblings` stops before cloning when the folder is not that name +# (opensoft/openRepoShape#543). A family whose two names differ has no layout +# that works for both unless both are accepted. A directory called Northwind +# is a directory called Northwind; what makes it a family folder is read, +# never guessed from a name. is_family_folder() { local dir="$1" name manifest name="${dir##*/}" @@ -246,8 +255,11 @@ is_family_folder() { manifest="$dir/$name/family.yaml" yaml_scalar "$manifest" kind || return 1 [ "$YAML_VALUE" = "family-manifest" ] || return 1 - yaml_scalar "$manifest" name || return 1 - [ "$YAML_VALUE" = "$name" ] + if yaml_scalar "$manifest" name && [ "$YAML_VALUE" = "$name" ]; then + return 0 + fi + yaml_scalar "$manifest" repository || return 1 + [ "${YAML_VALUE##*/}" = "$name" ] } is_project_root() { @@ -626,6 +638,24 @@ manifest_project_field() { return 1 } +# THE `repository:` OF A FAMILY HOLDER THAT IS ALREADY HERE, in the folder +# `` (#236): `///family.yaml`, read only +# where `is_family_folder` accepts that folder. The manifest does not carry it, +# and a folder is the repository's name only under v1.1.0 - before that it was +# the display `name:`, which can be another word - so where a holder is on +# disk its own file is the one that knows. Sets `YAML_VALUE` and returns 0, or +# returns 1 when no holder is here or its file names no repository. +family_repository_on_disk() { + local folder="$1" dir + for dir in "${PROJECTS_DIRS[@]}"; do + is_family_folder "$dir/$folder" || continue + yaml_scalar "$dir/$folder/$folder/family.yaml" repository || continue + [ -n "$YAML_VALUE" ] || continue + return 0 + done + return 1 +} + # --- the refresh ------------------------------------------------------------ REFUSED=0 @@ -1161,8 +1191,9 @@ if [ -n "$MANIFEST_FILE" ]; then # from `family.yaml`'s `id:` — `workspace_family_name` reads that key # — and an id is LOWERCASE by construction (`family.py`'s # `--id ... or family.lower()`, checked against - # `PROJECT_ID_RE = ^[a-z0-9][a-z0-9-]*$`). The FOLDER is the family's - # `name:`, CamelCase, which is what `is_family_folder` requires to + # `PROJECT_ID_RE = ^[a-z0-9][a-z0-9-]*$`). The FOLDER is one of the + # family's CamelCase names - its repository's last part, or its + # display `name:` - which is what `is_family_folder` requires to # match the directory. Deriving the folder from `family:` therefore # produced `~/projects/inkrouter/inkrouter`, a clone of # `/inkrouter` that 404s on any case-sensitive remote, and then a @@ -1171,9 +1202,18 @@ if [ -n "$MANIFEST_FILE" ]; then # `workspace_manifest_root_value` writes each MEMBER's `root:` as # `/` with the real casing, so the # first segment is the family folder and — the holder repository being - # the family's own name in its org, #76 ruling 3's doubled-name layout - # — it is the holder repository's name too. `family:` stays what it is - # good for: the flag that says this file is a family's at all. + # the family's own name in its org, #76 ruling 3's doubled-name layout, + # and v1.1.0's rule (opensoft/openRepoShape#629) — it is the holder + # repository's name too. `family:` stays what it is good for: the flag + # that says this file is a family's at all. + # + # THE RECORD NEVER CARRIES THE HOLDER'S REPOSITORY (#236): it has the + # id in `family:`, each member's OWN `repository:` and `root:`, and is + # filed under the holder's org. So the repository is derived from that + # org and the folder, which is exact under v1.1.0 and is also what a + # fresh machine, with no `family.yaml` to read yet, has to go on. A + # holder this machine already has says which repository it is, and + # that is read below, once the folder is known to be a name. MANIFEST_DIRNAME="${MANIFEST_ROOT_VALUE%%/*}" [ -n "$MANIFEST_ROOT_VALUE" ] && [ "$MANIFEST_DIRNAME" != "$MANIFEST_ROOT_VALUE" ] || @@ -1202,6 +1242,9 @@ if [ -n "$MANIFEST_FILE" ]; then '$MANIFEST_DIRNAME' is not one: an estate folder is a single name under your projects directory, with no slash and no leading dot. Nothing was cloned. That file is written by \`park\`; a hand-edited one is fixed by hand." + if [ "$MANIFEST_KIND" = "family" ] && family_repository_on_disk "$MANIFEST_DIRNAME"; then + ROOT_REPOSITORY="$YAML_VALUE" + fi say " records a $MANIFEST_KIND estate, root repository $ROOT_REPOSITORY" fi diff --git a/status b/status index bb5c2559..155d0bd9 100755 --- a/status +++ b/status @@ -251,7 +251,8 @@ USAGE # first time somebody copied only one of them. The rules themselves are NOT # new: `//family.yaml` carrying `kind: family-manifest` and naming # `` is `setup-project.py`'s `family_folder_here`, doubled-name layout -# and all (#76, RULING ruling 3). +# and all (#76, RULING ruling 3). What "naming" accepts is widened by #236, +# at `is_family_folder` below: the display `name:` or the repository's name. # `$PROJECTS_DIR` names ONE directory when it is set. Otherwise both # spellings, in that order, because people really do use both - and DEDUPED by @@ -372,11 +373,19 @@ is_pinned_member() { [ "$YAML_VALUE" = "family-manifest" ] } -# Is `` a FAMILY FOLDER? All three facts have to agree, exactly as -# `family_folder_here` requires them to: the holder clone sits at -# `/`, its `family.yaml` says `kind: family-manifest`, and it -# names that same family. A directory called Northwind is a directory called -# Northwind; what makes it a family folder is read, never guessed from a name. +# Is `` a FAMILY FOLDER? All three facts have to agree: the holder clone +# sits at `/`, its `family.yaml` says `kind: family-manifest`, +# and the folder is named for the family. A family has TWO names and either +# one is the folder's (#236): its display `name:`, which is the rule this +# block began with and what an estate laid out before openRepoShape v1.1.0 +# has, or the LAST PART of its `repository:`, which is v1.1.0's rule - the +# holder, and the folder around it, are named for the REPOSITORY, `name:` +# being only a title (opensoft/openRepoShape#629), and the holder's `make +# siblings` stops before cloning when the folder is not that name +# (opensoft/openRepoShape#543). A family whose two names differ has no layout +# that works for both unless both are accepted. A directory called Northwind +# is a directory called Northwind; what makes it a family folder is read, +# never guessed from a name. is_family_folder() { local dir="$1" name manifest name="${dir##*/}" @@ -384,8 +393,11 @@ is_family_folder() { manifest="$dir/$name/family.yaml" yaml_scalar "$manifest" kind || return 1 [ "$YAML_VALUE" = "family-manifest" ] || return 1 - yaml_scalar "$manifest" name || return 1 - [ "$YAML_VALUE" = "$name" ] + if yaml_scalar "$manifest" name && [ "$YAML_VALUE" = "$name" ]; then + return 0 + fi + yaml_scalar "$manifest" repository || return 1 + [ "${YAML_VALUE##*/}" = "$name" ] } is_project_root() { diff --git a/tests/test_park_resume_commands.py b/tests/test_park_resume_commands.py index 8cf874fe..a0249228 100644 --- a/tests/test_park_resume_commands.py +++ b/tests/test_park_resume_commands.py @@ -132,6 +132,31 @@ path: "." """ +#: A FAMILY WHOSE DISPLAY `name:` IS NOT ITS REPOSITORY'S NAME (#236). +#: `family.py init --family Quill --name QuillWorks` writes exactly this: +#: `name: "QuillWorks"` beside `repository: testorg/Quill`. `FAMILY_YAML` +#: above always writes `repository: {org}/{name}`, so no test built on it can +#: tell the two names apart. The holder is NAMED AFTER THE REPOSITORY under +#: openRepoShape v1.1.0 (opensoft/openRepoShape#629) and `make siblings` stops +#: before cloning when it is not (opensoft/openRepoShape#543). +FAMILY_TWO_NAMES_YAML = """\ +schema_version: 1 +kind: family-manifest +id: {id} +name: "{name}" +org: {org} +repository: {repository} +tracking_branch: main +members_dir: members +members:{members} +""" + +#: The two names of that one family: the folder v1.1.0 prescribes, which is +#: its repository's last part, and the display name the commands used to +#: require instead. +QUILL = "Quill" +QUILL_DISPLAY = "QuillWorks" + # --- the environment every run gets ---------------------------------------- @@ -201,6 +226,24 @@ def probe_family(projects: Path, name: str) -> Path: return holder +def probe_family_two_names(projects: Path, folder: str, *, name: str, + repository: str | None) -> Path: + """A family folder like `probe_family`'s, but `family.yaml` carries a + display `name:` and a `repository:` of their own (#236), so the folder can + equal one, the other, neither. `repository=None` leaves the key out.""" + holder = projects / folder / folder + holder.mkdir(parents=True) + text = FAMILY_TWO_NAMES_YAML.format( + id=folder.lower(), name=name, org=ORG, repository=repository, + members=" []") + if repository is None: + text = "".join(line for line in text.splitlines(keepends=True) + if not line.startswith("repository:")) + (holder / "family.yaml").write_text(text, encoding="utf-8") + (holder / "Makefile").write_text(PROBE_MAKEFILE, encoding="utf-8") + return holder + + def probe_project(projects: Path, name: str, *, at: Path | None = None) -> Path: """A standalone project root with a probe Makefile.""" root = at if at is not None else projects / name @@ -529,6 +572,85 @@ def estate(remotes, home) -> dict: "siblings": siblings} +@pytest.fixture(scope="session") +def named_apart(tmp_path_factory, remotes) -> dict: + """The family of #236, whose display name is not its repository's name, + as bare repositories: the holder `Quill.git` and a workspace `wip.git`. + + The holder is `remotes["holder"]` with ONLY `family.yaml` rewritten — `id: + quill`, `name: "QuillWorks"`, `repository: testorg/Quill` — so it still + carries the template bytes and the pinned members every other holder here + does. The bare is called `Quill.git` because that is what `resume` asks + for: `clone_url_for testorg/Quill`. + + The workspace carries TWO records of it, one per folder a person might + have laid it out in. Each is `family_manifest`'s own, so `root:` is + `/` and the record says nothing about the holder's + repository: `family:` is the id, and the org is the directory it is filed + under. + """ + base = tmp_path_factory.mktemp("namedapart") + seed = base / "seed" / QUILL + seed.parent.mkdir(parents=True) + git("-c", "protocol.file.allow=always", "clone", "-q", + "--recurse-submodules", str(remotes["holder"]), str(seed), cwd=base) + family_yaml = seed / "family.yaml" + text = family_yaml.read_text(encoding="utf-8") + rewritten = (text + .replace(f"id: {FAMILY.lower()}\n", f"id: {QUILL.lower()}\n") + .replace(f'name: "{FAMILY}"\n', f'name: "{QUILL_DISPLAY}"\n') + .replace(f"repository: {ORG}/{FAMILY}\n", + f"repository: {ORG}/{QUILL}\n")) + # A rewrite that matched nothing would leave `TestFam` in a fixture that + # claims not to be it, and every test below would be about the wrong file. + assert FAMILY not in rewritten, rewritten + assert (f'name: "{QUILL_DISPLAY}"\n' in rewritten + and f"repository: {ORG}/{QUILL}\n" in rewritten), rewritten + family_yaml.write_text(rewritten, encoding="utf-8") + commit_all(seed, "the family's display name is not its repository's") + holder = base / "remotes" / f"{QUILL}.git" + holder.parent.mkdir(exist_ok=True) + git("clone", "-q", "--bare", str(seed), str(holder), cwd=base) + workspace = seed_workspace( + base, "wip", + {f"workspaces/{ORG}/{QUILL.lower()}.yaml": family_manifest(QUILL), + f"workspaces/{ORG}/{QUILL_DISPLAY.lower()}.yaml": + family_manifest(QUILL_DISPLAY)}) + return {"dir": base / "remotes", "holder": holder, "workspace": workspace} + + +def clone_quill_estate(named_apart: dict, remotes: dict, home: Path, + folder: str) -> dict: + """`estate`'s own shape, for the family of #236 laid out in `folder`: + the holder at `/` and each member's working clone beside + it. `folder` is `QUILL` (v1.1.0's rule) or `QUILL_DISPLAY` (the rule + before it); the holder's own `family.yaml` is the same in both.""" + projects = home / "projects" + where = projects / folder + where.mkdir(parents=True, exist_ok=True) + git("-c", "protocol.file.allow=always", "clone", "-q", + "--recurse-submodules", str(named_apart["holder"]), + str(where / folder), cwd=projects) + siblings = {} + for name in MEMBERS: + git("clone", "-q", str(remotes["members"][name]), + str(where / name), cwd=projects) + siblings[name] = where / name + return {"projects": projects, "folder": where, "holder": where / folder, + "siblings": siblings} + + +def place_quill_workspace(named_apart: dict, home: Path) -> None: + """The configured workspace, already cloned: what `resume` reads a record + out of on a machine that has resumed before (`behind`'s own setup).""" + config = home / ".agents" / "workspace.yaml" + config.parent.mkdir(parents=True) + config.write_text(f"repository: {WORKSPACE_SLUG}\npath: ~/projects/wip\n", + encoding="utf-8") + git("clone", "-q", str(named_apart["workspace"]), + str(home / "projects" / "wip"), cwd=home) + + # =========================================================================== # estate resolution — the half both commands share # =========================================================================== @@ -569,6 +691,84 @@ def test_a_family_folder_wins_over_a_standalone_root_of_the_same_name(home): assert f"PROBE park in {projects / 'Dual'} " not in result.stdout +# --- a family folder is named for its repository OR its display name (#236) -- +# +# `is_family_folder` required `family.yaml`'s display `name:` to equal the +# folder, and openRepoShape v1.1.0 names the folder after the holder's +# REPOSITORY instead (opensoft/openRepoShape#629), stopping `make siblings` +# before it clones when it is not (opensoft/openRepoShape#543). A family whose +# two names differ had no layout both could use. Either name now finds it. + +@pytest.mark.parametrize("command", [PARK, RESUME], ids=["park", "resume"]) +@pytest.mark.parametrize("folder", [QUILL, QUILL_DISPLAY], + ids=["repository", "name"]) +def test_a_family_folder_is_found_by_its_repository_name_or_its_display_name( + named_apart, remotes, home, command, folder): + """`Quill/Quill` is v1.1.0's layout and `QuillWorks/QuillWorks` the one the + commands required before it; one `family.yaml` (`name: "QuillWorks"`, + `repository: testorg/Quill`) is found in both, by the folder's own name.""" + quill = clone_quill_estate(named_apart, remotes, home, folder) + place_quill_workspace(named_apart, home) + result = run(command, folder, "--dry-run", home=home, + env=offline(named_apart)) + assert result.returncode == 0, result.stderr + result.stdout + assert "no estate named" not in result.stderr + assert f"{quill['holder']}" in result.stdout + if command == PARK: + assert (f"park: {folder} - the family holder at {quill['holder']}" + in result.stdout) + for name in MEMBERS: + assert f"--- {name}: make park ---" in result.stdout + else: + assert f"present {quill['folder']} (family)" in result.stdout + + +@pytest.mark.parametrize("command", [PARK, RESUME], ids=["park", "resume"]) +@pytest.mark.parametrize("shape", [ + ("Elsewhere", "Other", f"{ORG}/Other"), + (ORG, "Other", f"{ORG}/Quill/Other"), + ("Quill", "Other", None), +], ids=["neither-name-nor-repository", + "an-earlier-part-of-the-repository", "no-repository-and-another-name"]) +def test_a_family_folder_that_is_neither_name_still_refuses( + remotes, home, command, shape): + """What the second arm must NOT widen into. The folder has to equal the + LAST part of `repository:` — not any part, not the org, and not whatever a + missing key reads as — or `name:`; anything else is a directory called + that, as it always was.""" + folder, name, repository = shape + projects = home / "projects" + probe_family_two_names(projects, folder, name=name, repository=repository) + config = home / ".agents" / "workspace.yaml" + config.parent.mkdir(parents=True) + config.write_text(f"repository: {WORKSPACE_SLUG}\npath: ~/projects/wip\n", + encoding="utf-8") + result = run(command, folder, home=home, env=offline(remotes)) + assert result.returncode == 2, result.stdout + result.stderr + if command == PARK: + assert f"no estate named '{folder}'" in result.stderr + assert f"PROBE park in {projects / folder / folder}" not in result.stdout + else: + assert f"no workspace manifest records '{folder}'" in result.stderr + assert f"present {projects / folder}" not in result.stdout + + +def test_a_family_folder_named_for_its_repository_wins_over_a_standalone_root( + home): + """The same rule as above, for a family whose `name:` differs: the holder + drives the members, so it is still the answer where a standalone + `project.yaml` sits in the same folder.""" + projects = home / "projects" + holder = probe_family_two_names(projects, QUILL, name=QUILL_DISPLAY, + repository=f"{ORG}/{QUILL}") + probe_project(projects, QUILL, at=projects / QUILL) + result = run(PARK, QUILL, home=home) + assert result.returncode == 0, result.stderr + result.stdout + assert f"park: {QUILL} - the family holder at {holder}" in result.stdout + assert f"PROBE park in {holder}" in result.stdout + assert f"PROBE park in {projects / QUILL} " not in result.stdout + + def test_with_no_name_the_estate_around_the_cwd_is_used(home): """`park` from a subdirectory, which is how a person actually types it.""" projects = home / "projects" @@ -1250,6 +1450,49 @@ def test_resume_on_a_fresh_machine_builds_the_whole_estate(remotes, home): assert "0 refusal(s)" in result.stdout +def test_resume_clones_the_holder_under_its_repositorys_name_when_the_display_name_differs( + named_apart, remotes, home): + """#236, the fresh machine. The record says `family: quill` and each + member's `root: Quill/Alpha`, and it is filed under `workspaces/testorg/`: + it NEVER carries the holder's repository, so `resume` derives it from the + org and the family folder — and under v1.1.0 the folder IS the + repository's last part (opensoft/openRepoShape#629). The clone then has to + be recognised as an estate although its `name:` is "QuillWorks"; before, + `resume` cloned the right repository and refused it as 'not an estate'.""" + result = run(RESUME, QUILL, "--workspace", WORKSPACE_SLUG, home=home, + env=offline(named_apart)) + assert result.returncode == 0, result.stderr + result.stdout + folder = home / "projects" / QUILL + holder = folder / QUILL + assert f"root repository {ORG}/{QUILL}" in result.stdout + assert f"absent cloning {ORG}/{QUILL} -> {holder}" in result.stdout + assert f"{ORG}/{QUILL_DISPLAY}" not in result.stdout + assert 'name: "QuillWorks"' in (holder / "family.yaml").read_text( + encoding="utf-8"), "the holder is the one whose display name differs" + assert not (home / "projects" / QUILL_DISPLAY).exists() + for name in MEMBERS: + assert (folder / name / ".git").is_dir(), f"{name} is not beside it" + assert f"{name}.sh ran with:" in result.stdout + assert f"holder {QUILL}" in result.stdout + assert "0 refusal(s)" in result.stdout + + +def test_resume_names_the_repository_the_holders_family_yaml_gives( + named_apart, remotes, home): + """#236, the estate already here, laid out before v1.1.0: the folder is the + display name, so it is NOT the repository's name, and a repository derived + from it (`testorg/QuillWorks`) is a repository that does not exist. The + holder's own `family.yaml` says which one it is, and it can be read.""" + clone_quill_estate(named_apart, remotes, home, QUILL_DISPLAY) + place_quill_workspace(named_apart, home) + result = run(RESUME, QUILL_DISPLAY, "--dry-run", home=home, + env=offline(named_apart)) + assert result.returncode == 0, result.stderr + result.stdout + assert (f"records a family estate, root repository " + f"{ORG}/{QUILL}") in result.stdout + assert f"{ORG}/{QUILL_DISPLAY}" not in result.stdout + + def test_resume_writes_the_config_only_when_asked_by_name(remotes, home): """One of the two files this standard ever writes outside a repository — and it is the SAME file as the other, `~/.agents/workspace.yaml`, written diff --git a/tests/test_status_command.py b/tests/test_status_command.py index b5c1b5bc..0c7f549a 100644 --- a/tests/test_status_command.py +++ b/tests/test_status_command.py @@ -74,6 +74,10 @@ ORG, MEMBERS, FAMILY, FAKE_SHA, commit_all, estate, family_manifest, remotes, run, probe_project, ) +from test_park_resume_commands import ( # noqa: F401 (fixtures by name) + ORG, QUILL, QUILL_DISPLAY, clone_quill_estate, named_apart, + probe_family_two_names, +) STATUS = REPO / "status" @@ -558,6 +562,60 @@ def test_a_family_reads_the_holder_and_each_working_clone_and_names_a_missing_on assert "1 finding(s) in 3 repositories" in result.stdout +@NEEDS_UPSTREAM +@pytest.mark.parametrize("folder", [QUILL, QUILL_DISPLAY], + ids=["repository", "name"]) +def test_a_family_folder_is_read_by_its_repository_name_or_its_display_name( + named_apart, remotes, home, folder): + """#236: one `family.yaml` (`name: "QuillWorks"`, `repository: + testorg/Quill`), laid out in the folder openRepoShape v1.1.0 prescribes — + its repository's last part, opensoft/openRepoShape#629 — and in the one + `status` required before it, the display name. Both are one estate: the + holder, then each member's working clone beside it.""" + quill = clone_quill_estate(named_apart, remotes, home, folder) + result = run(STATUS, folder, home=home) + assert result.returncode == 0, result.stdout + result.stderr + assert "no estate named" not in result.stderr + assert f"holder main {quill['holder']}" in result.stdout + for name in MEMBERS: + assert f"root main {quill['siblings'][name]}" in result.stdout + assert "0 finding(s) in 3 repositories" in result.stdout + + +@pytest.mark.parametrize("shape", [ + ("Elsewhere", "Other", f"{ORG}/Other"), + (ORG, "Other", f"{ORG}/Quill/Other"), + ("Quill", "Other", None), +], ids=["neither-name-nor-repository", + "an-earlier-part-of-the-repository", "no-repository-and-another-name"]) +def test_a_family_folder_that_is_neither_name_is_still_not_an_estate(home, + shape): + """The repository arm must not widen past the LAST part of `repository:`, + and a missing key must not read as a name: those folders are directories + called that, exactly as they were before.""" + folder, name, repository = shape + probe_family_two_names(home / "projects", folder, name=name, + repository=repository) + result = run(STATUS, folder, home=home) + assert result.returncode == 2, result.stdout + result.stderr + assert f"no estate named '{folder}'" in result.stderr + + +def test_a_family_folder_named_for_its_repository_wins_over_a_standalone_root( + home): + """The holder drives the members, so where a family whose `name:` differs + shares its folder with a `project.yaml`, `status` reads the family.""" + projects = home / "projects" + holder = probe_family_two_names(projects, QUILL, name=QUILL_DISPLAY, + repository=f"{ORG}/{QUILL}") + probe_project(projects, QUILL, at=projects / QUILL) + result = run(STATUS, QUILL, home=home) + assert "no estate named" not in result.stderr, result.stderr + assert (f"status: {QUILL} - the family holder at {holder}" + in result.stdout), result.stdout + result.stderr + assert "the assembly root" not in result.stdout + + # --- every estate ------------------------------------------------------------ @pytest.mark.parametrize("flag", ["--all", "-a"]) From c824e4e676603199683f27ffbcb8bb6af8cdc517 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 02:43:03 +0000 Subject: [PATCH 2/3] status tests: do not import ORG twice (#236) Co-Authored-By: Claude Sonnet 5.5 --- tests/test_status_command.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_status_command.py b/tests/test_status_command.py index 0c7f549a..11e34b8b 100644 --- a/tests/test_status_command.py +++ b/tests/test_status_command.py @@ -75,7 +75,7 @@ remotes, run, probe_project, ) from test_park_resume_commands import ( # noqa: F401 (fixtures by name) - ORG, QUILL, QUILL_DISPLAY, clone_quill_estate, named_apart, + QUILL, QUILL_DISPLAY, clone_quill_estate, named_apart, probe_family_two_names, ) From 85a49d91643bf23500c8fb30393eec40ea950ff4 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 03:48:39 +0000 Subject: [PATCH 3/3] resume names the repository of the holder it resolved, not the one the record's folder points at; status exit code, a nested repository: negative case, and yaml_scalar's key list (#236) Applies the independent review 5561239588 of #240. family_repository_on_disk looked the holder up by the record's folder, so a record gone stale after make siblings' own mv (root: QuillWorks/Alpha, the holder now at Quill/Quill) missed a holder that is on disk and printed testorg/QuillWorks, which is no repository. resume now reads family.yaml of the estate it resolved (), after both ESTATE_PRESENT lookups, and says its 'records' line there; the record-derived repository stays as the fallback. The helper is gone. Tests: the stale-record case (and the same record against the holder still in the display name's folder, typed as the id), a member's own repository: that is never the holder's (park, resume, status), and the status 'wins over a standalone root' test now checks the exit is not 2. The yaml_scalar header comment lists repository, byte for byte in all three. Co-Authored-By: Claude Sonnet 5.5 --- park | 3 +- resume | 44 ++++++++---------- status | 3 +- tests/test_park_resume_commands.py | 74 ++++++++++++++++++++++++------ tests/test_status_command.py | 21 +++++---- 5 files changed, 97 insertions(+), 48 deletions(-) diff --git a/park b/park index 81048b50..65e8cac4 100755 --- a/park +++ b/park @@ -200,7 +200,8 @@ is_safe_folder_name() { # One indent-zero scalar out of a small YAML file, with parameter expansion # only: no YAML library, no python per line, no forks per line. This is the # reader the Speckit extension's `workspace_read_scalar` is, narrowed to the -# keys these two commands read (`kind`, `name`, `family`, `tracking_branch`). +# keys these two commands read (`kind`, `name`, `family`, `tracking_branch`, +# `repository`). YAML_VALUE="" yaml_scalar() { local file="$1" key="$2" line diff --git a/resume b/resume index b3d2a967..7f874004 100755 --- a/resume +++ b/resume @@ -183,7 +183,8 @@ is_safe_folder_name() { # One indent-zero scalar out of a small YAML file, with parameter expansion # only: no YAML library, no python per line, no forks per line. This is the # reader the Speckit extension's `workspace_read_scalar` is, narrowed to the -# keys these two commands read (`kind`, `name`, `family`, `tracking_branch`). +# keys these two commands read (`kind`, `name`, `family`, `tracking_branch`, +# `repository`). YAML_VALUE="" yaml_scalar() { local file="$1" key="$2" line @@ -638,24 +639,6 @@ manifest_project_field() { return 1 } -# THE `repository:` OF A FAMILY HOLDER THAT IS ALREADY HERE, in the folder -# `` (#236): `///family.yaml`, read only -# where `is_family_folder` accepts that folder. The manifest does not carry it, -# and a folder is the repository's name only under v1.1.0 - before that it was -# the display `name:`, which can be another word - so where a holder is on -# disk its own file is the one that knows. Sets `YAML_VALUE` and returns 0, or -# returns 1 when no holder is here or its file names no repository. -family_repository_on_disk() { - local folder="$1" dir - for dir in "${PROJECTS_DIRS[@]}"; do - is_family_folder "$dir/$folder" || continue - yaml_scalar "$dir/$folder/$folder/family.yaml" repository || continue - [ -n "$YAML_VALUE" ] || continue - return 0 - done - return 1 -} - # --- the refresh ------------------------------------------------------------ REFUSED=0 @@ -1213,7 +1196,7 @@ if [ -n "$MANIFEST_FILE" ]; then # org and the folder, which is exact under v1.1.0 and is also what a # fresh machine, with no `family.yaml` to read yet, has to go on. A # holder this machine already has says which repository it is, and - # that is read below, once the folder is known to be a name. + # that is read below, from the estate this run resolved. MANIFEST_DIRNAME="${MANIFEST_ROOT_VALUE%%/*}" [ -n "$MANIFEST_ROOT_VALUE" ] && [ "$MANIFEST_DIRNAME" != "$MANIFEST_ROOT_VALUE" ] || @@ -1242,10 +1225,6 @@ if [ -n "$MANIFEST_FILE" ]; then '$MANIFEST_DIRNAME' is not one: an estate folder is a single name under your projects directory, with no slash and no leading dot. Nothing was cloned. That file is written by \`park\`; a hand-edited one is fixed by hand." - if [ "$MANIFEST_KIND" = "family" ] && family_repository_on_disk "$MANIFEST_DIRNAME"; then - ROOT_REPOSITORY="$YAML_VALUE" - fi - say " records a $MANIFEST_KIND estate, root repository $ROOT_REPOSITORY" fi # THE ESTATE ON DISK WINS over the manifest's idea of where it is: the manifest @@ -1258,6 +1237,23 @@ if [ "$ESTATE_PRESENT" -eq 0 ] && [ -n "$MANIFEST_DIRNAME" ] && fi fi +# WHAT THE RECORD SAYS THE ESTATE IS, said once the estate on disk is known, +# because a holder that is here knows its own repository (#236): the HOLDER +# THIS RUN RESOLVED, whatever the record's folder is. A record goes stale the +# moment a person moves the holder to the folder v1.1.0 names (`make +# siblings`' own `mv`), and a lookup by the record's folder would then miss a +# holder that is on disk and print a repository that does not exist. Nothing +# above prints, so the order of the lines a run says is unchanged. +if [ -n "$MANIFEST_FILE" ]; then + if [ "$MANIFEST_KIND" = "family" ] && [ "$ESTATE_PRESENT" -eq 1 ] && + [ "$ESTATE_KIND" = "family" ] && + yaml_scalar "$ESTATE_ROOT/family.yaml" repository && + [ -n "$YAML_VALUE" ]; then + ROOT_REPOSITORY="$YAML_VALUE" + fi + say " records a $MANIFEST_KIND estate, root repository $ROOT_REPOSITORY" +fi + # --- (3) rebuild or refresh the estate -------------------------------------- say "" say "(3) the estate" diff --git a/status b/status index 155d0bd9..c2f65796 100755 --- a/status +++ b/status @@ -321,7 +321,8 @@ is_safe_folder_name() { # One indent-zero scalar out of a small YAML file, with parameter expansion # only: no YAML library, no python per line, no forks per line. This is the # reader the Speckit extension's `workspace_read_scalar` is, narrowed to the -# keys these two commands read (`kind`, `name`, `family`, `tracking_branch`). +# keys these two commands read (`kind`, `name`, `family`, `tracking_branch`, +# `repository`). YAML_VALUE="" yaml_scalar() { local file="$1" key="$2" line diff --git a/tests/test_park_resume_commands.py b/tests/test_park_resume_commands.py index a0249228..5430924b 100644 --- a/tests/test_park_resume_commands.py +++ b/tests/test_park_resume_commands.py @@ -227,15 +227,23 @@ def probe_family(projects: Path, name: str) -> Path: def probe_family_two_names(projects: Path, folder: str, *, name: str, - repository: str | None) -> Path: + repository: str | None, + member_repository: str | None = None) -> Path: """A family folder like `probe_family`'s, but `family.yaml` carries a display `name:` and a `repository:` of their own (#236), so the folder can - equal one, the other, neither. `repository=None` leaves the key out.""" + equal one, the other, neither. `repository=None` leaves the key out, and + `member_repository` adds one member whose own, INDENTED `repository:` is + that: a key that is never the holder's.""" holder = projects / folder / folder holder.mkdir(parents=True) + rows = " []" + if member_repository is not None: + rows = (f"\n - project: Alpha\n id: alpha\n" + f" repository: {member_repository}\n" + f" path: members/Alpha\n") text = FAMILY_TWO_NAMES_YAML.format( id=folder.lower(), name=name, org=ORG, repository=repository, - members=" []") + members=rows) if repository is None: text = "".join(line for line in text.splitlines(keepends=True) if not line.startswith("repository:")) @@ -640,14 +648,16 @@ def clone_quill_estate(named_apart: dict, remotes: dict, home: Path, "siblings": siblings} -def place_quill_workspace(named_apart: dict, home: Path) -> None: +def place_quill_workspace(named_apart: dict, home: Path, + bare: Path | None = None) -> None: """The configured workspace, already cloned: what `resume` reads a record - out of on a machine that has resumed before (`behind`'s own setup).""" + out of on a machine that has resumed before (`behind`'s own setup). + `bare` is another workspace than `named_apart`'s own.""" config = home / ".agents" / "workspace.yaml" config.parent.mkdir(parents=True) config.write_text(f"repository: {WORKSPACE_SLUG}\npath: ~/projects/wip\n", encoding="utf-8") - git("clone", "-q", str(named_apart["workspace"]), + git("clone", "-q", str(bare or named_apart["workspace"]), str(home / "projects" / "wip"), cwd=home) @@ -725,20 +735,24 @@ def test_a_family_folder_is_found_by_its_repository_name_or_its_display_name( @pytest.mark.parametrize("command", [PARK, RESUME], ids=["park", "resume"]) @pytest.mark.parametrize("shape", [ - ("Elsewhere", "Other", f"{ORG}/Other"), - (ORG, "Other", f"{ORG}/Quill/Other"), - ("Quill", "Other", None), + ("Elsewhere", "Other", f"{ORG}/Other", None), + (ORG, "Other", f"{ORG}/Quill/Other", None), + ("Quill", "Other", None, None), + ("Quill", "Other", None, f"{ORG}/Quill"), ], ids=["neither-name-nor-repository", - "an-earlier-part-of-the-repository", "no-repository-and-another-name"]) + "an-earlier-part-of-the-repository", "no-repository-and-another-name", + "only-a-members-repository-ends-in-the-folder"]) def test_a_family_folder_that_is_neither_name_still_refuses( remotes, home, command, shape): """What the second arm must NOT widen into. The folder has to equal the - LAST part of `repository:` — not any part, not the org, and not whatever a - missing key reads as — or `name:`; anything else is a directory called + LAST part of `repository:` — not any part, not the org, not whatever a + missing key reads as, and not a MEMBER's own `repository:` (indented, so + never the holder's) — or `name:`; anything else is a directory called that, as it always was.""" - folder, name, repository = shape + folder, name, repository, member_repository = shape projects = home / "projects" - probe_family_two_names(projects, folder, name=name, repository=repository) + probe_family_two_names(projects, folder, name=name, repository=repository, + member_repository=member_repository) config = home / ".agents" / "workspace.yaml" config.parent.mkdir(parents=True) config.write_text(f"repository: {WORKSPACE_SLUG}\npath: ~/projects/wip\n", @@ -1477,6 +1491,38 @@ def test_resume_clones_the_holder_under_its_repositorys_name_when_the_display_na assert "0 refusal(s)" in result.stdout +@pytest.mark.parametrize( + "on_disk, asked", [(QUILL, QUILL), (QUILL_DISPLAY, "quill")], + ids=["the-holder-was-moved-to-the-repositorys-name", + "the-holder-is-still-in-the-display-names-folder"]) +def test_resume_names_the_repository_of_the_holder_it_resolved_not_the_records_folder( + named_apart, remotes, home, tmp_path, on_disk, asked): + """The review of #240 (5561239588), finding 1. The record is filed as park + files it: ONE per family, named for the id (`quill.yaml`, `family: quill`), + parked from the layout before v1.1.0 so its `root:` is `QuillWorks/Alpha`. + Then `make siblings`' own advice is followed, `mv QuillWorks/QuillWorks + Quill/Quill`, and the record is stale until the next park. The holder IS + on disk, found by the name typed; asking for the repository by the + record's folder missed it and printed `testorg/QuillWorks`, which is no + repository. The second case is the same record against the holder still + where the record says, typed as the id.""" + clone_quill_estate(named_apart, remotes, home, on_disk) + record = family_manifest(QUILL_DISPLAY).replace( + f"family: {QUILL_DISPLAY.lower()}\n", f"family: {QUILL.lower()}\n") + assert "root: QuillWorks/Alpha" in record and "family: quill\n" in record + place_quill_workspace(named_apart, home, seed_workspace( + tmp_path / "stale", "wip", + {f"workspaces/{ORG}/{QUILL.lower()}.yaml": record})) + result = run(RESUME, asked, "--dry-run", home=home, + env=offline(named_apart)) + assert result.returncode == 0, result.stderr + result.stdout + assert (f"records a family estate, root repository " + f"{ORG}/{QUILL}") in result.stdout + assert f"{ORG}/{QUILL_DISPLAY}" not in result.stdout + assert f"present {home / 'projects' / on_disk} (family)" \ + in result.stdout + + def test_resume_names_the_repository_the_holders_family_yaml_gives( named_apart, remotes, home): """#236, the estate already here, laid out before v1.1.0: the folder is the diff --git a/tests/test_status_command.py b/tests/test_status_command.py index 11e34b8b..89b549a3 100644 --- a/tests/test_status_command.py +++ b/tests/test_status_command.py @@ -583,19 +583,23 @@ def test_a_family_folder_is_read_by_its_repository_name_or_its_display_name( @pytest.mark.parametrize("shape", [ - ("Elsewhere", "Other", f"{ORG}/Other"), - (ORG, "Other", f"{ORG}/Quill/Other"), - ("Quill", "Other", None), + ("Elsewhere", "Other", f"{ORG}/Other", None), + (ORG, "Other", f"{ORG}/Quill/Other", None), + ("Quill", "Other", None, None), + ("Quill", "Other", None, f"{ORG}/Quill"), ], ids=["neither-name-nor-repository", - "an-earlier-part-of-the-repository", "no-repository-and-another-name"]) + "an-earlier-part-of-the-repository", "no-repository-and-another-name", + "only-a-members-repository-ends-in-the-folder"]) def test_a_family_folder_that_is_neither_name_is_still_not_an_estate(home, shape): """The repository arm must not widen past the LAST part of `repository:`, - and a missing key must not read as a name: those folders are directories - called that, exactly as they were before.""" - folder, name, repository = shape + a missing key must not read as a name, and a MEMBER's own `repository:` + (indented) is never the holder's: those folders are directories called + that, exactly as they were before.""" + folder, name, repository, member_repository = shape probe_family_two_names(home / "projects", folder, name=name, - repository=repository) + repository=repository, + member_repository=member_repository) result = run(STATUS, folder, home=home) assert result.returncode == 2, result.stdout + result.stderr assert f"no estate named '{folder}'" in result.stderr @@ -610,6 +614,7 @@ def test_a_family_folder_named_for_its_repository_wins_over_a_standalone_root( repository=f"{ORG}/{QUILL}") probe_project(projects, QUILL, at=projects / QUILL) result = run(STATUS, QUILL, home=home) + assert result.returncode != 2, result.stdout + result.stderr # 1: findings assert "no estate named" not in result.stderr, result.stderr assert (f"status: {QUILL} - the family holder at {holder}" in result.stdout), result.stdout + result.stderr