Skip to content

family.yaml values are read the way openRepoShape v1.1.0 reads them, status names one family laid out twice, and resume finds a holder by its id when its record names another folder (#243) - #251

Merged
brettheap merged 3 commits into
mainfrom
fix/family-yaml-followups-243
Oct 18, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #243, the four loose ends the independent review of #240 (review 5561239588) named. Brett Heap's word (2026-10-18) asked lane 1 to take both follow-up issues of the #238 and #240 reviews. #242 is a separate PR (#249), which touches only park's report_repos; this one owns the shared estate resolver this round. That block stays byte-identical in park, resume and status (one md5 across the three; the byte-identity tests pass).

1. Value shapes: trim_unquote reads a scalar the way openRepoShape v1.1.0 does

Decision: fix it in the SHARED trim_unquote, not in is_family_folder alone. A cut there would have left the quotes on the quoted-with-comment form.

What changed. trim_unquote now follows v1.1.0's parse_yaml at 190133d (scripts/repo_shape.py: _strip_comment, _quote_end, _quoted_scalar; opensoft/openRepoShape#629):

  • A value that opens with a quote is the text up to its closing quote. Inside "…" a \ escape does not close it, and inside '…' a doubled '' does not. Anything after the close, a # comment included, is not the value, so a # inside quotes is text.
  • Any other value is cut at the first # that opens it or follows a space or tab, then trimmed. a#b stays a#b.
  • Escapes are honoured only to find the close and are never unescaped, as before. A quote nothing closes is not YAML, and the text is kept whole, as before.

yaml_scalar now also sets YAML_QUOTED. The repository arm of is_family_folder moved into a new shared named_for_repository. It treats an UNQUOTED null, Null, NULL or ~ as no repository (v1.1.0's _scalar spellings), so the folder falls back to name:. A quoted "null" is still the text.

family.yaml line v1.1.0 main this PR
repository: testorg/Quill # the holder in Quill Quill rejected found
repository: "testorg/Quill" # the holder in Quill Quill rejected found
repository: null in null (name Other) no repository accepted rejected
repository: "null" in null null accepted accepted
repository: "testorg/Quill #2" in Quill Quill #2 rejected rejected ( # stays literal)

Caller audit. These are every caller of trim_unquote and yaml_scalar in the three scripts. "Changes" means a value with a trailing comment, or a quoted value followed by more text, now reads differently.

Shared block (all three):

  • kind: (is_pinned_member, is_family_folder, is_project_root) and name: (is_family_folder): change. A trailing comment no longer keeps a holder or root from being recognised. That is right because v1.1.0 drops the comment and family.py never writes one.
  • repository: (named_for_repository): changes, per the table above.
  • id: (family_folders_with_id, new): comment cut. park.sh's workspace_read_scalar cuts at any #, so the id park files under never carries one.
  • tracking_branch: (tracking_branch_of, from family.yaml and project.yaml): changes. main # x is now main, which is what bootstrap.py and siblings.py read: both use repo_shape.load_yaml.
  • - project: (family_members): changes. Alpha # x is now the member Alpha, as siblings.py reads it.
  • role: and path: (legs_of, leg_branch_of): change. A commented path: spec # the spec leg is now spec, as bootstrap.py reads it.
  • branch: (leg_branch_of): unchanged. Its own ${branch%%[[:space:]]#*} cut runs first and is kept, and its 22-case table passes untouched.

resume only:

  • workspace.yaml's repository: and path: (yaml_scalar), and orgs: with its path: (config_org_paths): change. A trailing comment is cut. This now agrees with the extension's workspace_read_scalar; before, it read the comment into the path.
  • The record's family: (yaml_scalar) and its root: and repository: (manifest_project_field): change only for a commented value. Park writes none, and the extension's loader cuts at every #.
  • The holder's repository: on the records line: comment cut. A holder with an unquoted repository: null would still print null there. That line is print-only, the brief scopes the null rule to is_family_folder, and family.py always writes a repository. Noted, not changed.

status only:

  • Shape pin commit: and source_repository: (yaml_scalar), shape:'s commit: (nested_scalar), workspace path: (workspace_checkouts) and the estate id: (locate_record): comment cut. update-shape.py and park write none.
  • shape_pin_rows (path:, sha256:): unchanged except a tab before a comment, which is now cut. Its own # re-cut after trim_unquote is now redundant for an unquoted path. For a quoted path holding # it still cuts, as before, and update-shape.py writes no such path. Left as it was.
  • record_scalar and read_root_shape: these cut at EVERY # first, which is the extension loader's rule, so trim_unquote never sees one. The only difference is text after a closing quote ("a" b is now a), which is not YAML and which park never writes.
  • config_scalar does not use trim_unquote, and is unchanged.

2. Two layouts of one family: said, not de-duplicated

Decision: do NOT de-duplicate estates.

  • (a) AGENTS.md gets a six-line paragraph under the opening of "Driving park <Name>, …". Quill/Quill and QuillWorks/QuillWorks are both found, as two estates. Parking both writes ONE record key, the family's id:, so the later park replaces the first one's member blocks. Keep only the folder named for the repository, which is openRepoShape v1.1.0's rule and where make siblings moves a holder (make siblings and family.py siblings: clone each member on its tracking branch, warn before cloning outside a <Family> folder, and run the holder's pinned siblings.py openRepoShape#543).
    • The record-key claim was checked against the extension's source, not run. workBenches' park.sh sets MANIFEST_NAME="$WORKSPACE_FAMILY" (the family.yaml id:), and workspace_write_manifest merges by each project's - id:. A member's block is therefore replaced whichever layout parks it.
    • The paragraph moves AGENTS.md's cap from 323 to 330, with a dated entry in test_agents_md_is_short_enough_to_be_read. The assembly root's 347 does not move. assembly-0001 already fails on main's AGENTS.md (hunks 6 and 8), and applies or fails hunk for hunk the same with these lines (checked with patch --dry-run), so the re-cut sets that cap. tests/test_test_roots.py holds 347 until then.
  • (b) status reports a read-only FINDING on the holder's row when two family folders under the projects directory carry the same family.yaml id:. It names every folder of that id and the one to keep, the folder named for its repository, and moves nothing. This uses a new shared family_folders_with_id.

3. A stale record found by id (resume)

When estate_by_name "$NAME" and the record-folder lookup both miss, resume now looks for family folders under the projects directory, by either naming rule, whose family.yaml id: equals the record's family:.

  • Exactly one: that is the present estate. No clone is planned, the records line names the holder's own repository, and present names its folder, with one line saying it was found by id and that the record's root: names another folder until the next park.
  • More than one: REFUSED by name before anything is created. The refusal names every folder and the one to keep, consistent with item 2, and nothing is cloned over either.
  • None: the clone, as before.

A folder reached under both spellings of the projects directory counts ONCE. For example, a real ~/Projects may hold a link Quill -> ~/projects/Quill. A candidate that is -ef one already listed is skipped, and the first spelling, in PROJECTS_DIRS order, is the one named. So resume takes that holder, and status's two-layout finding (item 2b, which reads the same list) has nothing to report. Before this, the review of #251 (finding 1) showed resume quill refusing "2 family folders" and status Quill reporting the family "laid out 2 times" for one folder. Two real holders in two directories still refuse.

The existing is_safe_folder_name checks on <Name> and the record's root: still run before any path is built. The holder found by id is held to it too, and refused by name if its folder could not be an estate name.

4. Test gaps

  • The cwd walk-up for a repository-named family. From Quill/Quill/scripts, Quill/Quill and Quill, park --dry-run answers the holder; from Quill/Alpha it answers the member.
  • named_apart now files only quill.yaml, the record park writes. The quillworks.yaml park never writes for id: quill is gone.
    • test_resume_names_the_repository_the_holders_family_yaml_gives now asks resume quill against quill.yaml (root: Quill/Alpha) with the holder in QuillWorks/QuillWorks. This is item 3 in the other direction: before, resume planned a clone of testorg/Quill into Quill/Quill, beside the holder.
    • Every other test the fixture serves stays green. resume QuillWorks --dry-run now finds no record and refreshes the estate that is there.

Red and green

All runs below were under the shared pytest lock, with /usr/bin/python3 -m pytest, every LANES_* and TMUX* variable unset, and a private HOME, TMPDIR, --basetemp and PYTHONPYCACHEPREFIX.

At a6156dc. New and changed tests, plus every named_apart user (47 tests):

  • Against main's park, resume and status: 20 failed, 27 passed.
    • Failing: the 3 issue rows; the 11 comment, escape and quote shapes of the trim_unquote table; the on-disk test (a clone of Quill/Quill was planned); the found-by-id test (a clone of QuillWorks/QuillWorks was planned); both two-holder refusals and the unsafe-name refusal (main ran mkdir and then git clone, exit 128); and the status two-layout finding (exit 0, no finding).
    • Passing on main, as guards: the 2 quoting guards ("null", the literal #), the 11 shapes main already read right, the 4 walk-up cases, the clone plan for an absent family, and the 9 named_apart users.
  • With this PR: 47 passed.

Whole modules at a6156dc:

  • tests/test_park_resume_commands.py: 217 passed (181 on main plus 36 new).
  • tests/test_status_command.py: 210 passed (209 plus 1).
  • tests/test_repo_hygiene.py: 189 passed, the byte-identity tests included.
  • tests/test_test_roots.py::test_the_length_caps_are_the_assembly_roots_where_one_is_named: passes.

Fix round for the review of #251, at 39b8a5d. This adds one test: test_one_holder_reached_under_both_spellings_is_one_holder_not_two, next to the two-holder refusal. tests/test_park_resume_commands.py now holds 218 tests and the new and changed set 48.

  • Red, at a6156dc's scripts: the new test fails (status Quill exit 1, "family quill is laid out 2 times"). Its two refusal neighbours pass: 1 failed, 2 passed.
  • Green, at the head, scoped:
    • The new test, both refusal rows, and the found-by-id, unsafe-name, absent-family, on-disk and two stale-record tests, plus test_the_three_commands_carry_the_same_resolver_byte_for_byte: 10 passed, 0 skipped.
    • tests/test_status_command.py: 210 passed.
    • tests/test_repo_hygiene.py: 189 passed.
    • The whole park/resume module was not rerun this round; CI is the record for the whole suite.
  • The review's NIT: a new helper, second_projects_spelling, makes ~/Projects beside the fixture's ~/projects. Where the two names are one directory (a case-insensitive filesystem, such as a stock Mac), it SKIPS with a stated reason instead of failing on the mkdir. The under-the-other-spelling row and the new test both use it, and Linux runs both. The skip was shown with ~/Projects linked to ~/projects, the same samefile answer a case-folding filesystem gives; no macOS run was made.

Static checks:

  • bash -n and shellcheck -s bash are clean on all three scripts.
  • The shared block has one md5 across the three.
  • No ${x,,}, mapfile, declare -A, local -n, awk -v, [[ or <<< in the added lines. The new code uses ${x:off:len}, $(( )), case and while, all of which bash 3.2 has.

leg_branch_of's comment in the shared block now names everyone who asks it, including park's not-carried report (#242, PR #249). Its # shellcheck disable=SC2329 stays because #249 has not landed: without it, park draws exactly one SC2329 on this base.

🤖 Generated with Claude Code

Summary by Sourcery

Align estate resolution with openRepoShape v1.1.0 and safely handle duplicate or stale family layouts across park, resume, and status.

New Features:

  • Make estate discovery and resume handling follow openRepoShape v1.1.0 scalar and folder-naming rules.
  • Detect families laid out in multiple folders and report the repository-named folder to keep.
  • Recover a present family holder by its family id when a record points to a different folder.

Bug Fixes:

  • Correct parsing of quoted values, comments, escapes, and unquoted YAML null values across park, resume, and status.
  • Avoid treating one holder reached through multiple projects-directory spellings as multiple family layouts.
  • Prevent resume from cloning over an existing family or proceeding when multiple holders make the estate ambiguous.

Enhancements:

  • Keep the shared estate resolver behavior identical across park, resume, and status.
  • Document the consequences of duplicate family layouts and update coverage for repository-named families and walk-up discovery.

Documentation:

  • Document how duplicate family layouts are reported and which folder should be retained.

Tests:

  • Add coverage for YAML scalar parsing, repository value shapes, walk-up resolution, stale records, duplicate holders, and projects-directory aliases.

…status names one family laid out twice, and resume finds a holder by its id when its record names another folder (#243)

1. Value shapes. The shared `trim_unquote` reads a scalar as v1.1.0's
   `parse_yaml` does: a quoted value is the text up to its closing quote
   (a `\` or a `''` does not close it) and nothing after the close is the
   value; an unquoted one is cut at a `#` that opens it or follows a space
   or tab. `yaml_scalar` sets `YAML_QUOTED`, and the repository arm of
   `is_family_folder`, now `named_for_repository`, reads an UNQUOTED
   `null`, `Null`, `NULL` or `~` as no repository.
2. Two layouts of one family are not de-duplicated. AGENTS.md says that
   parking both writes one record key, the family id, and to keep the
   folder named for the repository; `status` reports a finding on the
   holder's row when two family folders under the projects directory carry
   the same `family.yaml` `id:`, naming both and the one to keep.
3. `resume` finds the holder by `family.yaml` `id:` against the record's
   `family:` when the name typed and the record's folder both miss: one
   match is the estate (no clone), two or more are refused by name and
   nothing is cloned, none is the clone it always was.
4. Tests for the cwd walk-up of a repository-named family, and
   `named_apart` files only the record park writes, `quill.yaml`.

The shared block gains `named_for_repository` and `family_folders_with_id`
and stays byte-identical in park, resume and status. `leg_branch_of`'s
comment names everyone who asks it, park's not-carried report (#242, PR
#249) included; its SC2329 disable stays until #249 lands. AGENTS.md's cap
moves 323 -> 330 with a dated entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 18, 2026

Copy link
Copy Markdown

Sorry @brettheap, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 2 days and 5 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Oct 18, 2026

Copy link
Copy Markdown

Reviewer's Guide

This PR synchronizes the shared shell YAML readers with openRepoShape v1.1.0, preserves and diagnoses families present in both supported folder layouts, and teaches resume to locate holders by family id before cloning; extensive tests cover parsing, discovery, ambiguity, safety, status findings, and command behavior.

Sequence diagram for resume finding a stale family by id

sequenceDiagram
    participant User
    participant Resume as resume
    participant Projects as Projects directory
    participant Holder as Family holder
    participant Clone as git clone

    User->>Resume: resume NAME
    Resume->>Projects: estate_by_name(NAME)
    Projects-->>Resume: no estate
    Resume->>Projects: find family folders by family.yaml id
    alt exactly one holder
        Projects-->>Resume: holder folder and repository
        Resume->>Resume: is_safe_folder_name
        Resume-->>User: found by id, no clone planned
    else more than one holder
        Projects-->>Resume: multiple folders
        Resume-->>User: REFUSED with folders and folder to keep
    else no holder
        Projects-->>Resume: no matching folder
        Resume->>Clone: clone repository into record root
        Clone-->>Resume: clone result
    end
Loading

Flow diagram for duplicate family layouts in status

flowchart TD
    A[status scans projects directory] --> B[family_folders_with_id]
    B --> C{Same family id in multiple folders?}
    C -- No --> D[Continue normal status]
    C -- Yes --> E[Keep repository-named folder]
    E --> F[Report read-only FINDING naming every folder]
    F --> G[Move nothing]
Loading

Flow diagram for YAML scalar parsing and family discovery

flowchart LR
    A[family.yaml scalar] --> B{Value starts with quote?}
    B -- Yes --> C[Read through matching quote; preserve escapes]
    B -- No --> D[Cut at comment marker after whitespace]
    C --> E[trim_unquote and yaml_scalar]
    D --> E
    E --> F{repository is unquoted null or ~?}
    F -- Yes --> G[Fall back to name]
    F -- No --> H[Use repository value]
    G --> I[is_family_folder]
    H --> I
    I --> J[Discover family estate]
Loading

File-Level Changes

Change Details Files
Align shared YAML scalar parsing with openRepoShape v1.1.0 and apply the resulting value semantics consistently across estate discovery and metadata readers.
  • Reworked trim_unquote to handle quoted scalars, escapes, comments, and whitespace like v1.1.0 while preserving escape text.
  • Added quoted-state tracking and shared repository-name resolution, including YAML null handling for unquoted null/~.
  • Updated callers across park, resume, and status to consume comment-trimmed values consistently, while retaining the existing branch-specific parsing.
park
resume
status
tests/test_park_resume_commands.py
Represent duplicate on-disk layouts of the same family explicitly instead of deduplicating them, and document the record-overwrite risk.
  • Added guidance explaining that repository-named and display-name-named folders are separate estates sharing one family record.
  • Added shared family-id folder detection for duplicate-layout reporting and repository-named-folder retention guidance.
  • Made status emit a read-only finding naming all duplicate folders and the folder to keep.
AGENTS.md
park
resume
status
tests/test_status_command.py
tests/test_repo_hygiene.py
Make resume recover an existing holder by matching its family.yaml id when name-based and record-folder lookups fail.
  • Added id-based holder discovery across supported family-folder naming rules before falling back to cloning.
  • Refuse ambiguous matches and unsafe discovered folder names without creating or cloning anything.
  • Report the discovered holder and stale record root, while preserving the existing clone behavior when no matching holder exists.
resume
tests/test_park_resume_commands.py
Expand regression coverage for parser compatibility, repository-named family traversal, duplicate layouts, and stale-record recovery.
  • Added parameterized scalar-shape and repository-resolution tests covering comments, quoting, escapes, hashes, and null spellings.
  • Added cwd walk-up and nearest-estate tests for repository-named families.
  • Added resume tests for unique, duplicate, unsafe, and absent id-based holder scenarios, plus status duplicate-layout coverage.
  • Updated fixtures and documentation-length assertions for the single-record family model.
tests/test_park_resume_commands.py
tests/test_status_command.py
tests/test_repo_hygiene.py

Assessment against linked issues

Issue Objective Addressed Explanation
#242 Make park’s not-carried report check each leg’s own declared branch, falling back to the root’s tracking branch when the leg has none, for both root and family reports. ❌ The PR changes the shared estate resolver and mentions that leg_branch_of will be used by park, but it does not update report_repos to obtain and emit each leg’s branch. The PR body explicitly assigns that change to separate PR #249.
#243 Make the shared Bash scalar reader interpret comments, quoted values, and unquoted YAML null spellings consistently with openRepoShape v1.1.0, so family-folder recognition handles the reported repository value shapes correctly. ✅
#243 Resolve the two-layout case by either de-duplicating families or documenting that both layouts are estates; make the chosen behavior clear and identify the duplicate layout to keep. ✅
#243 When the requested family and the record-named folder do not locate an estate, let resume find a unique on-disk holder whose family.yaml id matches the record's family, and safely handle multiple or unsafe matches. ✅
#243 Close the identified test gaps by covering cwd walk-up for a repository-named family and using the realistic single record keyed by family id in the relevant resume test fixture. ✅

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@brettheap

Copy link
Copy Markdown
Contributor Author

Independent review of #251 at a6156dc: LAND

Tested base: the merged tree 4b2e8ce4a672afd3197b88f132f65859b9ebbb55, which is #251 merged onto babd9fe (submodule gitlink 190133d, openRepoShape v1.1.0). park, resume and status in that tree are byte-identical to the PR head. Copilot is unavailable, so this review plus Linux CI is the gate. CI run 39144681625 is re-testing the same tree; I did not run the whole suite.

I found no defect that should stop the landing. One LOW defect is confirmed with a repro and a verified fix (finding 1): take it in this PR or file it. The rest is INFO or NIT.

What I verified

Scratch homes and basetemps under a private review directory. Every LANES_* and TMUX* variable unset. Private HOME, TMPDIR, --basetemp and PYTHONPYCACHEPREFIX, run under flock /tmp/openrepotools-pytest.lock, /usr/bin/python3 -m pytest, scoped runs only.

  1. Whole modules on the merged tree, all green.
    • tests/test_park_resume_commands.py: 217 passed. That includes test_the_three_commands_carry_the_same_resolver_byte_for_byte and the 22 leg_branch_of cases (collected by name: 22 parametrised rows).
    • tests/test_status_command.py: 210 passed.
    • tests/test_repo_hygiene.py: 189 passed.
    • tests/test_test_roots.py::test_the_length_caps_are_the_assembly_roots_where_one_is_named: 1 passed.
  2. Red and green, both ways, on the PR head. The 38 new or changed tests pass with the PR (38 passed). With main's park, resume and status and the PR's tests, 20 failed, 18 passed, which is the PR body's count. Failures include test_resume_finds_the_holder_by_its_id_when_the_record_names_another_folder, both ..._refuses_two_holders_... rows, the unsafe-name test, test_one_family_laid_out_twice_... and 11 test_trim_unquote_... rows (a-trailing-comment, a-tab-before-the-comment, both quote kinds with a comment, an-escaped-quote-does-not-close and others).
  3. trim_unquote against v1.1.0, differential. I cut trim_unquote and yaml_scalar out of park and drove them against repo_shape.parse_yaml loaded from the pinned submodule.
    • A 65-case table, and an exhaustive run of all 37,449 strings up to length 5 over the alphabet a b space tab # " ' \. v1.1.0 refuses 10,926 of them (not YAML); 26,470 agree. Main would have disagreed on 7,832 of the exhaustive set.
    • All 53 disagreements are the documented "escapes are honoured to find the close and never unescaped" rule: "a\"b" reads a\"b where v1.1.0 reads a"b; 'a''b' reads a''b; "a\\" reads a\\. None is a comment, a quote-close, a # or a CRLF case.
    • Agree with v1.1.0: quoted value with a trailing comment; a # inside quotes kept; an unquoted # cut; # at the start cut; tab before # cut; a#b, https://x.org/a#frag and feature/x#1 kept whole; empty; an unclosed quote kept whole; CRLF. null and "null" agree on text and on YAML_QUOTED (0 and 1).
    • The extension's own writer quotes only values that fail ^[A-Za-z0-9][A-Za-z0-9._/@:+-]*$ and escapes " and \ when it does (workspace-common.sh, scalar()); the bash readers return those escapes raw, as on main. Not a regression.
  4. Caller audit. I listed every trim_unquote, yaml_scalar, nested_scalar, manifest_project_field and record_scalar call site in all three scripts. The PR body's list is complete. record_scalar and read_root_shape cut at every # first, so trim_unquote never sees one. The extension's workspace_read_scalar also cuts at every #, so for workspace.yaml's repository: and path: the new reader is closer to it than main. leg_branch_of: its own ${branch%%[[:space:]]#*} cut runs first and the second read cannot cut again, because no git branch holds a space. I found no value with a space before a # that is legitimate in any key read.
  5. resume find-by-id, with the filesystem measured before and after (a snapshot of every path, size and mtime under the fake home, .git internals by name only):
    • One holder, real run, not --dry-run: stale record (root: QuillWorks/Alpha), holder at Quill/Quill. Exit 0, present ... (family), make resume ran in Quill/Alpha and Quill/Bravo, no new path outside .git, projects/ holds Quill and wip only.
    • Two holders (~/projects/Quill and ~/Projects/Quill): exit 2, both named, the keep line. Paths added: wip/.git/FETCH_HEAD and wip/.git/ORIG_HEAD only, which is step (1)'s workspace fast-forward and precedes every later refusal on main too. No QuillWorks in either projects directory, no clone, no mkdir.
    • Unsafe name (Quill Old): exit 2 with the "not a name an estate folder may have" refusal. Paths added: the same two .git files. projects/ holds Quill Old and wip only.
    • The scan is one level ("$dir"/*), read-only, and a candidate must pass is_family_folder (kind: family-manifest), so standalone roots are not matched. It runs in a process substitution, so it cannot clobber the caller's YAML_VALUE. The glob skips dot folders. is_safe_folder_name is applied to the found folder before use. PROJECTS_DIRS is deduped by inode.
  6. status reports the layout finding only on a holder row and only with two or more folders of one id. With one layout, no finding and no change of exit code (the PR's test asserts it). It changes nothing.
  7. named_for_repository: unquoted null, Null, NULL, ~ fall back to name:; a quoted one does not (green rows a-quoted-null-is-the-text-null and the differential's quoted flag).
  8. Static. bash -n and shellcheck -s bash (0.11.0) clean on all three. One md5 (210fa805...) across the three shared blocks. A grep of the 448 added lines finds none of ${x,,}, ${x^^}, mapfile, readarray, declare -A, local -n, [[, <<<, &>>, |&, ;;&, coproc, ${!x}, printf -v, read -t/-i/-N. (The one raw hit is the POSIX class in ${v%%[[:space:]]#*}, which main's leg_branch_of already uses.) The new code uses ${v:off:len}, $(( )), case, while and < <(...). No bash 3.2 binary here, so this is static only.
  9. AGENTS.md. 330 lines on the merged tree, the new cap, with a dated docstring entry in test_agents_md_is_short_enough_to_be_read. The only other assertion of the sum is tests/test_test_roots.py:363-381 (347 for an assembly root), which passes; nothing else asserts it. The paragraph is accurate against the extension's source: workspace_family_name reads id: from <family folder>/<family folder>/family.yaml, and workspace_write_manifest merges blocks by each project's - id: (read, not run, as the PR says).
  10. Body and hygiene. closingIssuesReferences lists only Follow-ups to #240: family.yaml value shapes, two layouts of one family, a stale record found by id, and two test gaps #243. No host paths in the diff.

Findings

1. LOW, confirmed. Two spellings of ONE folder are counted as two layouts.
family_folders_with_id never compares candidates by physical path. PROJECTS_DIRS is deduped by inode, but the folders inside two distinct projects directories are not.

Repro (scratch home, the named_apart fixture, stale record root: QuillWorks/Alpha): holder at ~/projects/Quill/Quill; ~/Projects a real directory holding a symlink Quill -> ~/projects/Quill.

  • resume quill --dry-run: exit 2, REFUSED ... 2 family folders under your projects directory carry id: quill, listing <home>/projects/Quill and <home>/Projects/Quill.
  • status Quill: exit 1, family quill is laid out 2 times ... at <home>/projects/Quill and <home>/Projects/Quill, and its remedy ("move the rest aside") would take the link away.

It needs a deliberate link between the two spellings, so it is rare, but the false finding then repeats on every status run.

Fix, in the shared block (all three copies, byte-identical):

family_folders_with_id() {
    local want="$1" dir child prior dup found=()
    [ -n "$want" ] || return 0
    for dir in "${PROJECTS_DIRS[@]}"; do
        [ -d "$dir" ] || continue
        for child in "$dir"/*; do
            [ -d "$child" ] || continue
            is_family_folder "$child" || continue
            yaml_scalar "$child/${child##*/}/family.yaml" id || continue
            [ "$YAML_VALUE" = "$want" ] || continue
            dup=0
            for prior in ${found[@]+"${found[@]}"}; do
                if [ "$child" -ef "$prior" ]; then dup=1; fi
            done
            [ "$dup" -eq 0 ] || continue
            found+=("$child")
            printf '%s\n' "$child"
        done
    done
}

Verified on a scratch copy of the merged tree with that change in all three scripts: bash -n and shellcheck clean, one md5 across the three blocks, and the same symlink repro gives resume quill --dry-run exit 0 (found by id, make resume dispatched in Quill/Alpha and Quill/Bravo) and status Quill 0 finding(s). Two real holders in different directories (~/projects/Quill and ~/Projects/Quill) still refuse with exit 2. The bash 3.2 idioms are ones this repository already uses (WORKSPACES+=(...) and ${WORKSPACES[@]+"${WORKSPACES[@]}"} in resume). Add a test for the symlink case beside test_resume_refuses_two_holders_of_one_id_and_clones_over_neither. If you would rather land as is, file it as an issue.

2. INFO, confirmed, by design. A bare status counts one doubled family as two estates with findings.
With Quill/Quill and QuillWorks/QuillWorks both present, bare status and status --all print the finding on each holder and end findings Quill (1), findings QuillWorks (1), 0 estate(s) in sync, 2 with findings. That follows from "two estates, each found by its own name", and each row naming both folders is useful. No change asked; say so in the issue text if a person asks why the footer says 2.

3. INFO, not reproduced. The id match ignores the record's org.
A holder with the same id: from another organisation under a different folder name would be taken as the estate (resume) or reported as a second layout (status). estate_by_name has the same property by folder name today, so this is consistent and not a regression. Comparing the holder's repository: owner with $MANIFEST_ORG is the fix if it ever matters.

4. INFO, unmeasured. Cost of the new trim_unquote.
A quoted value is now a per-character bash loop. The box was at load average 42 to 52 for the whole session, so I did not take a timing; one direct pair taken under load (2000 calls, quoted 40-character value with a comment, no subshell) read about 2.1 s against 0.08 s on main, which is a ratio and not a measurement. Exposure looks small: the extension quotes only values outside ^[A-Za-z0-9][A-Za-z0-9._/@:+-]*$, so a record carries few quoted fields.

5. NIT, not run on macOS. under-the-other-spelling makes ~/projects and ~/Projects in one home.
They are one directory on a stock Mac. probe_family_two_names does holder.mkdir(parents=True), so by reading it would raise FileExistsError there. Linux CI is unaffected. If local macOS runs matter, skip that row when (home/"projects").samefile(home/"Projects").

🤖 Generated with Claude Code

…f the projects directory reach it, and the second-spelling test skips where ~/Projects is ~/projects (#243, review of #251)

The review of #251, finding 1: `PROJECTS_DIRS` is deduped by inode, but the
folders inside two distinct projects directories were not compared, so a
real `~/Projects` holding a link `Quill -> ~/projects/Quill` listed one
holder twice. `resume quill` refused "2 family folders" and `status Quill`
reported the family "laid out 2 times", with a remedy that would have taken
the link away. A candidate that is `-ef` one already listed is now skipped
(the reviewer's verified edit, with `local -a found=()` as the block's
neighbours spell it), in the shared estate resolver, byte-identical in park,
resume and status. Two real holders in two directories still refuse.

The review's NIT: `second_projects_spelling` makes `~/Projects` beside the
fixture's `~/projects`, and SKIPS, saying why, where the two names are one
directory (a case-insensitive filesystem, a stock Mac) instead of failing on
the `mkdir`. The two-holder refusal's `under-the-other-spelling` row and the
new symlink test both use it; Linux runs both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap

Copy link
Copy Markdown
Contributor Author

Delta re-check of #251, a6156dc..39b8a5d: LAND

My finding 1 (a symlinked second spelling counted as two layouts) and the macOS NIT are both taken, and I found nothing new. Tested base: the merged tree a200b0342653b51a2f07cd9f83ac5167d663e6b8, which is 39b8a5d merged onto babd9fe (main is still babd9fe; park, resume and status there are identical to the PR head). CI run 39146469797 was still in progress when I looked, so CI remains the record for the rest of the suite; I did not run it.

What I checked

  1. The delta, read in full: 96 lines. Only park, resume, status and tests/test_park_resume_commands.py changed. The hunk in the three scripts is the same (one md5 over the added and removed lines in all three): family_folders_with_id skips a folder that is -ef one it already listed, so the first spelling, in PROJECTS_DIRS order, is the one named.

  2. One md5 across the three shared blocks: 25fbf725e21201c4eaf275ef57c33802. It matches the writer's. bash -n and shellcheck -s bash are clean on all three.

  3. Empty array under set -euo pipefail, bash 3.2, by static read. All three scripts set -euo pipefail. ${found[@]+"${found[@]}"} is the guarded expansion, so the empty first pass raises no unbound-variable error. The repository already runs the same three constructs on macOS: local -a summary=() in park and status, and WORKSPACES=(), ${WORKSPACES[@]+"${WORKSPACES[@]}"} on an empty array and WORKSPACES+=(...) in resume. found is local, and the function runs in a process substitution, so nothing leaks. No bash 3.2 binary here, so this is a static read.

  4. The skip helper cannot skip on Linux by accident. second_projects_spelling skips only when ~/Projects already exists and samefiles ~/projects. I ran its logic in three homes. A fresh home like the home fixture's: no skip, a distinct Projects is made. Projects as a link to projects (standing in for a folded filesystem): Skipped - case-insensitive filesystem.... A distinct Projects that already exists: no skip, and mkdir raises FileExistsError, which no test here arranges. In the run below nothing was skipped (-rs lists no skip, 31 passed).

  5. Scoped tests on the merged tree (LANES_* and TMUX* unset, private HOME, TMPDIR, --basetemp and PYTHONPYCACHEPREFIX, flock, /usr/bin/python3 -m pytest): 31 passed, 397 deselected. That is:

    • test_one_holder_reached_under_both_spellings_is_one_holder_not_two;
    • both test_resume_refuses_two_holders_of_one_id_and_clones_over_neither rows;
    • the found-by-id tests: test_resume_finds_the_holder_by_its_id_when_the_record_names_another_folder, test_resume_names_the_repository_the_holders_family_yaml_gives, test_resume_holds_a_holder_found_by_its_id_to_an_estate_folders_name and test_resume_still_clones_a_family_no_holder_here_carries_the_id_of;
    • test_the_three_commands_carry_the_same_resolver_byte_for_byte;
    • the 22 trim_unquote rows;
    • the status two-layouts test, which still finds two real layouts.

    Also tests/test_repo_hygiene.py plus the assembly-cap test in tests/test_test_roots.py: 190 passed.

  6. Red without the fix. I copied the merged tree and put the a6156dc scripts back. test_one_holder_reached_under_both_spellings_is_one_holder_not_two fails at its first assertion (status exits 1 on the false "laid out 2 times"); the two refusal rows still pass, as they should, because two real holders still refuse.

  7. gh pr view 251 --json closingIssuesReferences lists only Follow-ups to #240: family.yaml value shapes, two layouts of one family, a stale record found by id, and two test gaps #243, and the head is 39b8a5d.

No new findings. The INFO items in my earlier comment stand unchanged (the bare-status footer counting two estates, the id match ignoring the record's org, and the unmeasured cost of the trim_unquote loop).

🤖 Generated with Claude Code

…wups-243

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready landing gate: runs tests-macos once before the squash

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Follow-ups to #240: family.yaml value shapes, two layouts of one family, a stale record found by id, and two test gaps

1 participant