diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 09224590..06b21432 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -3840,6 +3840,7 @@ lane refused (only when refused) tree (branch, scratch, cache, sandbox, link) +leg (an assembly's checkouts only, T018) summary ``` @@ -3869,6 +3870,175 @@ the table and in `DISPOSITION.md`, which is written even when an error no read caught stops the run), and **2** when the self-check refused a removal; usage is 64, a `--live` path that names no tree included. +### A triad: its legs, their worktrees and the paired trees (T018) + +**Plan task T018 of the triad migration (opensoft/openRepoTools#186).** A lane's +checkout is an **assembly** when its own `project.yaml` declares a leg other than +itself — the standard's rule, as openRepoShape's `templates/assembly-root/project.yaml` +writes it: a top-level `legs:` list of `- role:` / `repository:` / `path:`, the +assembly's own leg at `path: "."`. One reader answers it for every lane tool: + +```sh +lanes-edit.sh project-legs # TAB TAB +``` + +It is `lane-start`'s rung-5 reading of `legs:`, line for line (a test holds the two +equal), and reads `repository:` and `path:` only — the manifest confers nothing, +and `role:` is never read. 0 with legs; 8 with none (no `project.yaml`, or one naming +no leg but the checkout itself); 1 where the manifest is there and cannot be read, +which is never "no legs"; 64 usage. It touches no workspace. + +**Why the sweep needed it.** A leg is a submodule: its `.git` is a file, +`git -C worktree list` names none of its worktrees, and a feature's +trees in the standard's three-leg layout, `/worktrees//{spec,code}`, +are worktrees of the LEGS' repositories. So a code tree was invisible to the sweep, +to `lane-end`'s gate, to the handoff's poll and to the daily report unless it had +been recorded with the code leg as its checkout — and a lane with unpushed work in +one passed `lane-end`. + +**Where the lane's checkout is an assembly**, `lane-worktrees sweep` also: + +* reads every leg, and every dependency nested in one (an initialized submodule of + the leg's own, as `code/upstream/openRepoShape` will be), as a **`leg` row** — + `clean`, `dirty`, `unpushed`, `dirty+unpushed`, `absent` (not checked out here) + or `unreadable`. A leg is the assembly's, shared by every lane the assembly + homes, as the assembly's own checkout is: **no `--yes` acts on a leg + checkout** (its branches are another matter: see `--branches` below). It is + COUNTED (exit 3, and `lane-end`'s gate) only where its HEAD's own `Lane:` trailer + is this lane's and it holds edits or commits origin lacks — the rule the sweep + already applies to `.claude/worktrees` — or where it could not be read; one + that holds another lane's work, or nobody's, is listed and counted for nobody; +* lists the worktrees git registers in **every leg** — a FOREIGN row names the leg + it is registered in — so a tree recorded with the leg as its checkout is the + lane's as any recorded tree is; +* takes **`/worktrees`** as a root: a tree there that the lane's + inventory does not record is the lane's by its HEAD's own `Lane:` trailer and by + nothing else, because the paired root, like `.claude/worktrees`, is per feature + and not per lane — and `lane-handoff` records a tree there only on that rule, or + where the lane's own writers file names it (below), so the inventory does not + take in other lanes' trees; a standalone clone there + is FOREIGN; the root, a feature directory or a leg's directory under it that + cannot be listed or searched refuses, exit **2**; +* with `--branches`, makes each leg's branches rows like the checkout's own, each + naming its leg, and **`--yes` deletes a merged lane branch there exactly as it + does in the checkout** — bundling first any commits only its reflog names, and + deleting its origin branch too where the row says `delete+remote`. No `--yes` + acts on a leg *checkout*. With `--fetch` (and under `--yes`), it asks each leg's + origin. + +A manifest, or a leg's own submodule list, that cannot be read refuses, exit +**2**. **A checkout with no `project.yaml` is asked nothing** — no helper call, no +read, no row — so a single repository's sweep, gate, handoff, report and `add` are +byte for byte what they were; `tests/test_triad_lane_tooling.py` holds every one of +them to the output `c45a452`'s tools printed. + +**`lane-end`'s gate** counts a `leg` row marked `retire` as a "leg checkout" it +names, with the remedy a person's (commit and push from inside it; no `--yes` is +offered when legs are all it counted), and lists a leg that holds work and is not +this lane's below the table as NOT counted — whether the gate passes or not. + +**`lane-handoff` and `lane-reconcile` (T018 (b)).** Where the lane's checkout is an +assembly, `lane-handoff` also looks in the **paired root**: +`/worktrees/` and `/`, at every +directory there with a `.git` of its own. The paired root is per feature and not per +lane — every lane the assembly homes keeps its feature trees there — so the handoff +takes a tree there as this lane's on the sweep's terms and on no wider ones. It +**polls and records a paired tree only where one of three things holds**: + +* this lane's inventory already names it; +* its HEAD's own last `Lane:` trailer is this lane's, on a commit origin's default + branch lacks — the sweep's own rule for that root; +* the `--writers-file` names it, because a writer with nothing committed yet has no + trailer to be found by. + +Those trees are in the WRITERS block and its count. **Every other checkout there is +another lane's, or nobody's**: one line in the top block under a heading of its own, +outside WRITERS — `- PAIRED ROOT, not this lane's: — branch …`, with its last +commit and its HEAD's own trailer, which is what finds its lane. It is not counted, not +recorded, and never a writer for the next session to relaunch; its own lane's handoff +records it. So one lane's `lane-end` never refuses on another lane's paired work, and +its `sweep --yes` never acts on it. This is option (a) of the pre-PR review's M1, as +lane 1 ruled on 2026-10-17 (#186, comment 6240800023, item 1; the analysis's §7 item +12): a handoff that recorded every paired tree made other +lanes' trees the running lane's inventory, and a tree two lanes' inventories both name +is `keep`, counted, in both sweeps. + +Every tree it polls is **recorded under the checkout it belongs to**. A worktree of a +leg's repository (a paired tree, or one `lane-worktrees add --checkout +/code` made under the lane's root) is recorded under that leg, the path +`project-legs` prints and `add` records. A tree the inventory already records under a +checkout of the tree's own repository keeps it: a dependency nested in a leg +(`--checkout /code/upstream/`) is no declared leg. Any other tree keeps +the lane's `dir`, as before. A leg that is not checked out here (never initialized, or +deinitialized) names no tree. A leg with a `.git` of its own that git cannot read is +`unreadable`, the sweep's word: one `- UNREADABLE LEG: …` line in WRITERS and a note on +stderr, and counted on stderr's `legs: N of the assembly's legs under unreadable` +line, never in WRITERS' `(N found)`. The lane is not held `SWAPPING` for the leg +itself. A polled tree that could then be of that leg's repository is one it cannot +place: its common git directory lies inside the assembly or its git directory (a +leg's `.git/modules/`) and is no readable leg's and not the assembly's own, or +the inventory records it under the unreadable leg. Such a tree is named, marked +`(NOT recorded)` in its WRITERS line, and not recorded, so the checkout `add` recorded +stays, and the lane stays `SWAPPING` for that tree. The `UNREADABLE LEG` line says how +many trees were withheld, or that none was. A tree of any other repository, such as a +worktree of an unrelated repository in the lane's own root, is recorded under the +lane's `dir`, as when every leg reads. A tree with a `.git` of its own that git cannot +read at all (a damaged `.git/modules/` takes every worktree of that leg with it) +is listed in WRITERS and counted, marked `(NOT recorded)` and `UNREADABLE`, never +recorded, and the lane stays `SWAPPING` for it. A checkout with no `project.yaml` +naming a leg passes over such a tree, as it always did. A worktree +a leg registers anywhere else is not polled +as a writer, just as the checkout's own registrations elsewhere are not; +`lanes-edit.sh lane-reconcile` reports it. The reconciliation reads what the legs hold +beside what the checkout holds. Every worktree git registers in a checked-out leg that +no sidecar names is a `TREE` row: `unmanaged`, or `stale-registration` where its +directory is gone, naming the leg. A checkout in the paired root that nothing else named +is `unmanaged`. A leg with a `.git` of its own that git cannot read is a `TREE` row +`unreadable` that requires recovery: the sweep's rule and word for its `leg` row, which +it counts. The verdict is not `indeterminate` for it, so the sweep still lists and +counts that leg. Nothing is adopted, deleted or overwritten. + +**A `project.yaml` that cannot be read is never "no legs".** `lane-handoff` still +completes (`R-A11-11`), but it does not poll the paired root. It says so in the top +block's WRITERS section (`- NOT POLLED: …`), files no sidecar for a tree it cannot +place (a tree of the lane's own repository is still recorded), and leaves the lane +`SWAPPING`. The reconciliation's verdict is `indeterminate`, said in the words the +sweep refuses with ("the legs of the assembly `` could not be read"): the sweep +reads that verdict first and refuses on it, exit **2**. The verdict is `indeterminate` +too where a leg's registrations, the paired root or a feature directory under it +cannot be listed, or a leg's directory under a feature cannot be searched. For the +same directories, and where the lane's inventory cannot be read (which paired trees it +names would then be unknown), `lane-handoff` gives the same three signs: a +`- NOT POLLED: …` line in WRITERS, a note on stderr, and the lane left `SWAPPING`. +**A checkout with no `project.yaml` is asked nothing**, and one whose +`project.yaml` names no leg but itself is asked only `project-legs`, which answers 8. +The handoff's output, top block and inventory, and the reconciliation, are byte for +byte what they were. `tests/test_triad_lane_tooling_handoff.py` holds them to the +output `c45a452`'s tools printed, on a repository laid out to look like a triad: a +submodule's worktree under `worktrees//code`, and a tree recorded with the +submodule as its checkout. + +**A parked paired tree is not the lane's to the sweep — a gap lane 1 has ruled on, and +not yet closed (docs/triad-lane-tooling-analysis.md §7, item 11).** On 2026-10-17 (#186, +comment 6240800023, item 2) lane 1 ruled that park's WIP commit carries a `Lane: ` +trailer and that `resume` records the tree it rebuilds with `lanes-edit.sh +set-lane-tree`. Both are workBenches changes and neither has landed, so what follows +holds until they do. The sweep and `lane-end`'s gate +give a tree under `/worktrees` to a lane by its HEAD's own `Lane:` trailer +and by nothing else, unless the lane's inventory names it. `make park` commits a dirty +leg tree as `wip: park — lane `, with no trailer, and `resume` +soft-resets those commits, leaving the work uncommitted on the commit below them. So a +parked or resumed paired tree is a FOREIGN row of `lane-worktrees sweep +--dry-run` ("… its HEAD carries no Lane: trailer of this lane's as its own"), counted +for nobody, and a gate that passes says nothing of it. **Before `lane-end` in an +assembly**, run that dry run and read every FOREIGN row under `/worktrees`: +one whose HEAD, or whose branch's upstream, is a `wip: park … — lane ` +commit, or that holds uncommitted edits, may be this lane's parked work. Land it or +hand it over first, or record it with `lanes-edit.sh set-lane-tree +--checkout /` so the gate counts it. `lane-handoff` does not record it +for you: on the sweep's terms it records a paired tree with no trailer of the lane's +only where the inventory or the writers file already names it. + ### Two protocol lines this act assumes (proposed for the amendment that ratifies it) 1. **A lane creates worktrees, never clones.** Every tree a lane works in is a @@ -3921,6 +4091,25 @@ A tree made any other way — a brief's own `git worktree add`, the harness's `isolation: worktree` — is still legal git and is FOREIGN to every sweep until `lanes-edit.sh set-lane-tree ` records it. +**In a triad (T018), a tree of a leg is made from the leg** — the guidance of +docs/triad-lane-tooling-analysis.md §4.4, proposal 1: + +```sh +lane-worktrees add --checkout /code +``` + +Where the lane's checkout is an assembly (its `project.yaml` declares a leg other +than itself, read by `lanes-edit.sh project-legs`), a tree made from any checkout of +a leg's repository — the leg itself, or a worktree of it, a paired +`/worktrees//code` included — is recorded with **the leg** as its +checkout (`/`), which every reader of the inventory enumerates; the +note names the leg, and says so where the checkout given was a worktree of it. Made +from the assembly itself (no `--checkout`), the tree is a worktree of the assembly, +whose legs a worktree does not check out: `add` makes it as before and says that its +legs are empty there, naming the `--checkout /` that makes a leg's +tree. A lane whose checkout has no `project.yaml` is asked nothing, and its `add` is +what it always was. A manifest that cannot be read refuses, exit **2**, nothing made. + ### The estate report — `sweep --all --dry-run --report` The net under every actor that never runs `lane-end`: **creation outpacing @@ -3945,8 +4134,9 @@ lane-worktrees sweep --all --dry-run --report [--estate ] [--post | |---|---| | FOREIGN repositories | a CLONE inside a worktree container (`.lane-worktrees/`, `.claude/worktrees`, `worktrees`, `-worktrees`), and a second clone of an origin the estate already has a checkout of: path, size, last commit, owner (the last `Lane:` trailer), and LOAD-BEARING with its dependents where something leans on it | | Orphaned worktrees of ENDED lanes | trees an inventory records, and directories under `.lane-worktrees/`, of a lane whose snapshot is `CLOSED` or whose log's last lane line is `ENDED` or `RETIRED` — with the sweep that retires them. Empty beside a record it could not read — a lane snapshot, an inventory, the register's lines, a directory the walk could not list — it says "none found in what could be read", never "none" (#170 B8); a failure that is no lane record (an ignored-file listing, say) is still a row of the last section but leaves "none" as it is (#179); so does Awaiting disposition | -| Unmerged branches | local branches of every checkout with no upstream, a gone upstream, a diverged one, or unpushed commits: tip, state, age, owner, and whether a worktree holds it. Never deleted; `main`, `master` and `rescue/*` are not listed | +| Unmerged branches | local branches of every checkout — and of every leg of an assembly (T018) — with no upstream, a gone upstream, a diverged one, or unpushed commits: tip, state, age, owner, and whether a worktree holds it. Never deleted; `main`, `master` and `rescue/*` are not listed | | Root main divergence | a checkout whose local `main` is ahead of `origin/main`; where every changed path is under `handoffs/` or `lanes/`, the remedy is Amendment 4's — those live in the workspace repository and are pushed per write | +| Assembly legs with uncommitted or unpublished work | **only where the estate holds an assembly with a leg checked out** (T018): every leg of a canonical checkout whose `project.yaml` declares legs, holding dirty or untracked paths or a HEAD no origin ref holds, or that could not be read — each named with its assembly. A leg is a submodule (its `.git` a file), so it was never a canonical checkout; it is now read as a checkout of its own here, and in the three branch sections, whose leg rows end with `the leg of ` | | Awaiting disposition | a `rescue/*` branch (local or on origin) and a DIRTY inventory tree whose git directory has been still for `aging_days` (14), with owner and age | | Caches and sandboxes | `--include-caches`' and `--include-sandboxes`' rows across every lane, by size | | Ignored directories over `ignored_report_mb` | every ignored directory of every repository at or over 50 MB (`du -sk`) | diff --git a/docs/triad-lane-tooling-analysis.md b/docs/triad-lane-tooling-analysis.md new file mode 100644 index 00000000..0dfa34f2 --- /dev/null +++ b/docs/triad-lane-tooling-analysis.md @@ -0,0 +1,712 @@ +# The triad split, lane-tooling side: what moves, what breaks, what to change + +**Date:** 2026-10-07 (finished 2026-10-08 on base `c4864ac`) · **Lane:** openRepoTools-3 · **Branch:** `feat/triad-lane-tooling`, +made from `origin/main` at `c4864ac` · **Slice:** (d), the lane-tooling side of the +assembly/spec/code split of `opensoft/openRepoTools`. + +**Plan read:** branch `004-migrate-to-triad` at `ff6f6a7` (the head of PR #187), read with +`git show` only: `specs/004-migrate-to-triad/{plan,tasks,spec,installer-design,research,data-model,quickstart,verification,adopter-rehearsal}.md` +and `openspec/changes/migrate-to-triad/{adoption-plan.yaml,design.md}`. Brett Heap confirmed +the leg names and their public visibility on 2026-10-07: `openRepoTools-spec` mounted at +`spec/` and `openRepoTools-code` mounted at `code/`. + +Every `file:line` below is on main `c4864ac` unless it names another revision. Main moved +to `837928a` while this was finished (#187 landed the plan, its `specs/004-migrate-to-triad/` and +`openspec/changes/migrate-to-triad/` byte-identical to `ff6f6a7`; #188 the openRepoShape repin); +that changed only `specs/`, `openspec/`, `contracts/openreposhape-pin.yaml` and the +`upstream/openRepoShape` gitlink, so no line cited here from a tool or a test moved, and the +branch stays on `c4864ac`. A claim +marked **measured** was run on a throwaway fixture in this lane's scratch directory; one +marked **derived** follows from the code or from git's documented behaviour and was not run. +**2026-10-18:** paths on the bench where this was read are written `~/projects/…`, the +bench's projects directory, as `tests/test_repo_hygiene.py` asks of a committed file since +#229 item 22; the one quoted `grep` pattern in §4.15 keeps the spelling it searched for. +This document decides nothing. Plan tasks are T006 (root collisions and leg guidance), T007 +(installer entry point), T009 (separate roots in fixtures and hygiene), T010 (CI and pin +checks) and T018 (feature selection, park/resume and bindings). + +--- + +## 0. Summary + +1. **None of the shipped lane tools reads `docs/`, `openspec/` or `specs/` at run time.** The + adoption plan's cross-leg follow-ups for `lane-start`, `lanes-edit.sh`, `openRepoTools` and + `claude-current` (adoption-plan.yaml:325-328 and :334) are comments, usage text, a URL, or + the object-key grammar. Each tool finds its helpers **beside itself**: `$SCRIPT_DIR`, + `$selfdir` or `__file__`. All of those helpers move to `code/` together, so that lookup + works unchanged. The only code that really reads across legs is in the **tests**. Lane 1's + PR #190 (`feat/test-roots-for-the-triad` at `c139e88`) gives the suite those roots, so this + branch carries no test change of its own (§6). **2026-10-13:** that was true of this + branch's first commit only. It now carries T018's shape-gated changes to five tools, + with two test modules of its own (`tests/test_triad_lane_tooling.py`, + `tests/test_triad_lane_tooling_handoff.py`) and their two goldens; it still changes none + of the suite's roots. The §4.4 note of 2026-10-09 and the §6 notes say what it carries. +2. **The installer is the most fragile surface.** The README one-liners and every + already-installed copy's own `openRepoTools --install` fetch files by **repository-root + path** from `opensoft/openRepoTools@main` (openRepoTools:70-71, :258-275, :1370-1394). Once + the root split lands, those paths sit under the `code` gitlink. The result: the + one-liners stop working, every installed copy's re-install **refuses** with nothing + placed, and `wip init` **silently** seeds from openRepoShape `main` instead of the pinned + commit (openRepoTools:2516). No patch made before cutover can avoid this without changing + today's behaviour. The T007 entry point has to land **in the root-split PR itself** (§4.10). +3. **Lanes keep working only if their `dir` stays at the assembly root**, and three things + change around them. First, `#n` expands against the assembly. Second, an OpenSpec claim + keyed on `opensoft/openRepoTools` searches the wrong repository's pull requests. Third, a + code-leg worktree can't be seen by the lane's sweep, the `lane-end` gate or the + `lane-handoff` poll unless it was made with `lane-worktrees add --checkout /code`. + The daily report never counts a submodule leg as a canonical checkout. And one live lane + outside this repository runs the **checkout copy** `~/projects/openRepoTools/lanes-edit.sh`, + which disappears from that path at the split (§4.4, §4.5, §4.15). +4. **The code leg needs its own `.gitattributes` and `.gitignore`.** Git applies no + superproject attribute to a path inside a submodule (**measured**, §4.13). The plan puts both + files at the root only. Two hygiene caps are also full: `AGENTS.md` is at its cap + (316/316) and `README.md` is at its cap (486/486). So T006's "add the AGENTS-shape line" + and "merge shape/README.md" fail the hygiene suite unless those caps are raised with dated + entries. + +--- + +## 1. Scope and method + +**In scope:** the lane tools (`lane-start`, `lane-end`, `lane-worktrees`, `lanes-edit.sh`, +`lanes-index`, `lane-handoff`, `lane-rename`, `lane`, `lanes`, `link-estates`, `repos.tsv`), +`openRepoTools --install` and `wip init`, the hooks it merges, the documents that name +checkout paths, the tests and CI that assume one root, and the register/handoff data in +`opensoft/brett-wip` that names `~/projects/openRepoTools` (read, never written). + +**Out of scope:** the estate verbs `park`, `resume` and `status`. They find estates by +`$PROJECTS_DIR` and run `make park`/`make resume`, and they already know three-leg roots +(AGENTS.md rule 4). `claude-current` and `claude-restart-check` appear only where the +installer places them. + +**How:** I read every named file (the installer and `wip init` parts of `openRepoTools`, +`lane-start`, `link-estates`, `tests/conftest.py`, `tests/run.sh`, `.github/workflows/tests.yml` +and AGENTS.md in full; the large tools by their path-bearing sections). I grepped every shipped +file for self-location (`BASH_SOURCE`, `SCRIPT_DIR`, `selfdir`, `__file__`), cross-leg directory +names, hook and settings writes, and `project.yaml` readers. I measured git's behaviour around +submodules and worktrees on a fixture of three local bare repositories. I also read lane 1's +Gate B receipt in `brett-wip/migration/openRepoTools/adopter-fix-20261007-iM20tv/`, whose +standard table is 45 spec / 49 code / 6 root of 100 tracked paths at `c4864ac`. That matches +`git ls-files | wc -l` = 100 here. + +--- + +## 2. What the plan documents say, checked + +| Claim (where) | Checked on `c4864ac` | +| --- | --- | +| No change to lane-start, lane-worktrees, lane-end, link-estates or repos.tsv beyond placing them in code (adoption-plan.yaml:163-230, 259-268) | True of the placement. Not enough for the bindings: see §4.4, §4.5, §4.15. `lane-worktrees` and `lanes-index` are absent from the committed plan (it predates them) and present in lane 1's regenerated rehearsal plan as code. | +| lane-start and lanes-edit.sh name `docs/` (adoption-plan.yaml:326-327); lanes-edit.sh names `openspec/` (:334) | They are named, but never read: §3. | +| lane-handoff names `openspec/` and the follow-ups omit it | lane-handoff:2439 is a comment citing `openspec/changes/add-supervised-context-restart/design.md`. It is not a read. | +| lane-start reads estate `project.yaml` legs (lane-start:1271-1293); lanes-edit.sh too (lanes-edit.sh:5039-5121) | True. Both readers already handle an assembly like openRepoTools's: §4.2, §4.5. | +| repos.tsv:52 has only `openRepoTools` | True. There are no `-spec`/`-code` rows. | +| Paired layout `worktrees//{spec,code}` (plan.md:207-208) has no code on main | True. No lane tool makes, polls or claims a tree there: §4.4. | +| installer-design.md: resolve, select, delegate; one immutable code identity checked against `contracts/code-pin.yaml`; `fetch_from_repo` :258, `collect_commands` :962, `collect_skills` :1370, `wip_shape_ref` :2502, `wip_fetch_template_file` :2519; REPO/REF :70-71 | Every line number holds. `code-pin` has 0 hits in tracked files. | +| installer-design.md:61-64 counts 15 names / 27 files / 29 artifacts | **Stale.** `INSTALLABLES` (openRepoTools:338) holds **17** names. With 3 skills and 3 command files at 2 paths each, that is **29 placed files + 2 hook entries = 31 artifacts**, which is the installer's own statement (openRepoTools:389-396). Placing by rename (#172, `3660224`) landed after the design. It changes the placement mechanics, not the sources. | + +--- + +## 3. The adoption plan's cross-leg follow-ups, line by line + +Lane 1's Gate B receipt (`logs/runB-cross-leg-grep.log`) lists file names only. Each hit +falls into one of these kinds: **comment**, **usage** (text a command prints), **URL**, +**key** (an object key, not a path), **fixture** (data a test builds), or **READ** (a file +opened at run time). + +| Follow-up | Hit on `c4864ac` | Kind | Change needed | +| --- | --- | --- | --- | +| claude-current names `docs/` (:325) | claude-current:89 | comment | none | +| | claude-current:123 | usage (`--help` prints `docs/README-claude-current.md`) | Text only: the path is repository-relative, so after the split it means `spec/docs/…` from the root or `../spec/docs/…` from `code/`. Changing it changes `--help`'s bytes, so this is a **proposal** for the T014 docs batch, not a patch. | +| lane-start names `docs/` (:326) | lane-start:294, :617 | comment | none | +| lanes-edit.sh names `docs/` (:327) | lanes-edit.sh:10387 | comment | none | +| openRepoTools names `docs/` (:328) | openRepoTools:1218 (comment); :1342 (URL `code.claude.com/docs/en/hooks`) | comment, URL | none | +| tests/run.sh names `docs/` (:329) | tests/run.sh:36 | comment | none | +| test_install_skill_and_hook.py (:330) | :73, :1339 | URL | none | +| test_lane_helpers.sh names `docs/` (:331) | :3738, :6228 `cat "$SRC_DIR/docs/README-lanes.md"` | **READ** | #190: `LANE_MANUAL`, skipped when the run has no spec root | +| test_lane_start_claude_current.py (:332) | :15 | docstring | none | +| "… and 1 more" (:333) = test_repo_hygiene.py | :1183, :1214-1217, :1233, :2390, :2624 | **READ** | #190: `doc()` → `ROOTS.path_for` | +| lanes-edit.sh names `openspec/` (:334) | :280, :2770 (comments); :2814-2818, :2895-2897 (grammar `owner/repo:openspec/changes/`); :12110 (comment) | key | No path change. The **repository half** of the key changes meaning: §4.5. | +| test_lane_helpers.sh names `openspec/` (:335) | :1280, :2086, :3781 (object keys of a fake `opensoft/repoE`); :13343 (comment) | fixture, comment | none | +| test_park_resume_commands.py names `specs/` (:336) | :350, a `feature_directory: worktrees/…/spec/specs/…` string in a three-leg record the test writes | fixture | none | +| (not listed) lane-handoff names `openspec/` | lane-handoff:2439 | comment | none | + +--- + +## 4. Surface by surface + +### 4.1 How every tool finds its helpers (all tools) + +| Tool | Self-location (`c4864ac`) | Helpers found beside itself | +| --- | --- | --- | +| lane-start | :339-341 `SCRIPT_DIR` from `readlink -f` | lanes-edit.sh :600-604, claude-current :650, lanes :868, lane :917, lane-worktrees :4307 | +| lane-end | :259-261 | lanes-edit.sh :520, lane-worktrees :532 | +| lanes-edit.sh | :634-639 | lanes-index :2256, repos.tsv :2453, lane-start :9189 | +| lane-handoff | :119-120 | lanes-edit.sh :399, lane-start :685 | +| lane / lanes / lane-rename | lane:114-115 / lanes:102-103 / lane-rename:73-74 | lanes-edit.sh (lane:188, lanes:191, lane-rename:155), lane-start (lane:1166) | +| lanes-index | :325 `dirname(realpath(__file__))` | lanes-edit.sh | +| lane-worktrees | :441, :5278 | lanes-edit.sh, status | +| openRepoTools | :72 `SELF`; :971, :1372, :2504, :2521 `selfdir` | the 17 installables, `skills/`, `commands/`, `contracts/openreposhape-pin.yaml`, `upstream/openRepoShape/templates/…` | + +**Under the triad:** every one of these helpers is code-leg content (adoption-plan.yaml:107-323), +so `code/` finds `code/` exactly as `` finds `` today. Installed +copies in `~/.local/bin` are unaffected. **No change; T006 confirms it.** + +### 4.2 `lane-start` + +| Line | What it does | Under the triad | +| --- | --- | --- | +| :608 | `PROJECTS_ROOT=${PROJECTS_ROOT:-$HOME/projects}` | unchanged | +| :1116-1357 | The lane's directory, by six rungs: `--dir`, the swap record, the lane log's `dir`, `$PROJECTS_ROOT/`, the estate `project.yaml` legs (:1291-1318), then a checkout named for the home and proved by its origin (:1319-1328) | Lanes openRepoTools-1, -2 and -3 record `dir ~/projects/openRepoTools` (§4.15), so rungs 2 and 3 answer. If the assembly stays at that path (an in-place adoption of the live checkout), **nothing changes**. A lane homed at `opensoft/openRepoTools-code` (none exists) would be resolved by rung 5: the reader at :1301-1309 matches `repository:` against the home and resolves `path: code` against the manifest's directory. The shell suite already covers rung 5 on a declared `legs:` manifest (tests/test_lane_helpers.sh:5691-5704). IRRS, IRSS and MedxEHR carry the same block on this workstation today. | +| :1118-1128 | "the harness keys a session to the directory it was started in" (Evidence 3) | **This is why a lane's `dir` should stay at the assembly root.** Re-homing a lane into `code/` changes the Claude project folder (`-workspace-projects-openRepoTools` → `-workspace-projects-openRepoTools-code`). That strands the lane's memory, and `--resume ` stops finding the transcript. **T018 proposal:** bindings keep `dir` = assembly root, and nothing is rebound. | +| :1180-1199 | The home is written from the directory's `origin` | At the assembly root the origin is still `opensoft/openRepoTools`, so the home is unchanged. | +| :4294-4340 | Starts the daily report (`lane-worktrees sweep --all --dry-run --report`) for `$PROJECTS_ROOT` | Inherits the report's blind spot: §4.4. | +| hooks | lane-start writes **no** hook and no `settings.json` (`grep` finds only comments at :142, :1783) | none | + +**Patch:** none needed. **Proposal (T018):** the bindings decision above, plus a fixture test +that runs rung 5 against an openRepoTools-shaped manifest (§7). + +### 4.3 `lane-end` + +The close-out gate (:195-211, :1908-2045) runs `lane-worktrees sweep --branches +--include-scratch --include-caches --dry-run --porcelain`. It then reads every tree with +`git status --ignored` (:1927-1945) and every entry under `${dir%/*}/.lane-worktrees/` +(:2024-2045). Its roots and its trees are lane-worktrees'. **Under the triad it is as blind as +the sweep (§4.4).** A lane with unpushed work in a paired `worktrees//code` tree, or +in any code-leg tree it never recorded, passes the gate. **Patch:** none. **Proposal:** the +lane-worktrees roots change in §4.4 closes this too. + +### 4.4 `lane-worktrees`: sweep inventory, `add`, and the daily report + +**Measured** on a fixture (assembly `A` with `code` and `spec` submodules, and a worktree +of the code leg at `A/worktrees/001-x/code`): + +- `git -C A/code rev-parse --show-toplevel` → `A/code`, and `--show-superproject-working-tree` → `A`; +- `A/code/.git` is a **file**, `gitdir: ../.git/modules/code`; +- `git worktree add` inside the submodule succeeds (git 2.43); +- `git -C A worktree list` lists **only `A`**. The code leg's tree appears only under + `git -C A/code worktree list`, whose main entry is spelled `A/.git/modules/code`; +- `git -C A status` shows `?? worktrees/`. The shape's assembly `.gitignore` template + (`templates/assembly-root/.gitignore` at the pinned openRepoShape) does not ignore it. + +| Line | What it reads | Under the triad | +| --- | --- | --- | +| :2165-2173 `_roots` | `/.claude/worktrees` and `${dir%/*}/.lane-worktrees/` | Unchanged for `dir` = assembly. The paired `A/worktrees//{spec,code}` is **in neither root**. | +| :2175-2248 `_discover` | inventory sidecars + `git worktree list` of the lane's checkout and of every inventoried `checkout` | A code-leg tree is seen **only if** an inventory row names `checkout: /code`. A tree made by `make resume`/Speckit in the paired layout is never seen. | +| :2122-2163 `_lane_claims` | trailer claims under `.claude/worktrees` only | Paired trees are never claimed. | +| :2709-2713, :2759-2763 | scratch and caches under `.lane-worktrees/` | unchanged | +| :5381-5557 `add` | `git worktree add` of `--checkout` or the lane's `dir`, at `${dir%/*}/.lane-worktrees//`, then `set-lane-tree … --checkout ` | Without `--checkout`, it makes a worktree of the **assembly**, with empty, uninitialised `code/` and `spec/` (a worktree does not initialise submodules). **With `--checkout /code` it works today:** `:5448-5451` accepts it because `--show-toplevel` is the submodule root (measured above). | +| :1091-1125 `estate_repos` | walks into a repository only through `worktrees`, `.worktrees`, `.claude`, or a child with `.git` | `A/worktrees/` has no `.git`, so `A/worktrees//code` is never reached (derived). | +| :4884-4907, :4887 `Report.build` | `clones = [r … if os.path.isdir(r/.git)]` → canonical checkouts → branches, root-main, aging | A leg's `.git` is a file, so **a submodule leg is never canonical**. The code leg's branches, which are where all lane-tooling work happens after the split, never reach the daily report. This is already true today for every leg of IRRS, IRSS, MedxEHR and openDox on this workstation. | +| :4760-4770 `_under_container` | `worktrees`, `.worktrees`, `*-worktrees`, `.lane-worktrees/` | Recognises `A/worktrees`. Unaffected. | + +**Patch:** none. Every fix changes what the sweep or the report says **today** about the +estate's existing triads, and the brief rules out behaviour changes. **Proposals (T018):** + +1. **Guidance, no code:** under the triad a lane makes a code tree with + `lane-worktrees add --checkout /code`. That records the tree + with the code leg as its checkout, so every reader above sees it. +2. `_roots` adds `/worktrees` when `/project.yaml` names legs. Trees there are + claimed only by the HEAD `Lane:` trailer rule `_lane_claims` already applies to + `.claude/worktrees`, because that directory is per-feature, not per-lane. The same root + goes into `lanes-edit.sh:12699-12705` (`lane_worktree_roots`) and + `lane-handoff:1994-1996`, so the three readers cannot disagree. +3. `Report.build` treats a checkout whose `.git` is a file pointing into + `/.git/modules/` as canonical when the superproject is canonical. + This changes today's report for IRRS and the other existing triads, so it needs a ruling. + +#### 2026-10-09: what this branch now implements of §4.4 (T018), and what moves for every shape + +**On the coordinator's brief for slice (d2)** (worktree visibility for triad legs, +plan task T018), proposals 1–3 above are implemented as **shape-gated** changes, +one commit each with its tests (`tests/test_triad_lane_tooling.py`): (a) the sweep and +`lane-end`'s gate (proposal 2); (b) `lane-handoff`'s poll of the paired root, each +tree recorded under its leg, and `lane-reconcile`'s leg registrations (proposal 2's +other two readers — a second writer's, on `feat/triad-lane-tooling-handoff`, which +this branch merges with its own tests); (c) the daily report (proposal 3); and (d) +`lane-worktrees add` recording the leg it made a tree from, with proposal 1's +guidance in `docs/README-lanes.md` and the usage text. The gate is +the standard's own rule: the lane's checkout (or the checkout a tool polls) is an +**assembly** when its own `project.yaml` declares a leg other than itself. One +reader answers it for every tool, `lanes-edit.sh project-legs `, whose +seven deciding awk lines are `lane-start`'s rung-5 lines verbatim (a test holds +them equal). A checkout with no `project.yaml` is asked nothing, so on a single +repository every output, exit code, register line and inventory row is the one +`c45a452`'s tools produce: the test stores that "before" as a golden file +generated once from `c45a452`'s own tools (`tests/fixtures/t018/`), because CI +checks out at depth 1, and compares every touched command's output on a single +repository — one with a submodule that is no leg, and one with a `project.yaml` +naming no leg but itself — against it. + +**Outputs that change regardless of shape (for lane 1's ruling).** Two kinds of +text move on every checkout, single repository included, because they are usage +text and not behaviour; the coordinator directed both kept and listed here: + +1. `lanes-edit.sh`'s refusal of an unknown subcommand (exit 2) now lists + `project-legs` among the subcommands it answers to, and its usage header (what + `lanes-edit.sh` with no argument prints) gains the `project-legs` line. + `tests/test_repo_hygiene.py` requires the refusal to list every arm. +2. The `--help` text of the touched commands gains the triad guidance: + `lane-worktrees --help` (a "A TRIAD" paragraph), `lane-end --help` (four lines + in THE CLOSE-OUT GATE), and the commands later commits touch, each named in + that commit's message. + +No exit code, register line, inventory row, sweep row, gate verdict or report +line moves for a checkout with no `project.yaml`. + +### 4.5 `lanes-edit.sh` + +| Line | What it does | Under the triad | +| --- | --- | --- | +| :2754-2765 `alias_lookup`; :2776-2847 `canon_object` | resolves a key's repository through repos.tsv. A spelled `owner/repo` is **always accepted** (:2834) | `opensoft/openRepoTools-code#n` works with no repos.tsv row. The bare `openRepoTools-code#n` is refused until the row exists (§4.9). | +| :2791-2813 | `#n` expands against the lane's **home** | Home stays `opensoft/openRepoTools`, so `#n` always means the **assembly's** issue or PR. The code leg numbers its PRs from #1, so `#3` from an openRepoTools lane is the assembly's #3, never the code leg's. **T018 proposal:** code-leg and spec-leg objects are spelled in full. The lane-collision protocol text should say so. | +| :2814-2818, :2895-2897 | `owner/repo:openspec/changes/` keys | Rule 1's sibling reads run `gh pr list --repo --search ` (:10249-10272). A change keyed on `opensoft/openRepoTools` therefore searches the **assembly's** PRs, while spec PRs land in `openRepoTools-spec`. **T018 proposal:** at cutover, each open claim on `opensoft/openRepoTools:openspec/changes/` is released and re-claimed as `opensoft/openRepoTools-spec:openspec/changes/`, through `lanes-edit.sh release`/`claim`. The log is append-only and is never rewritten. | +| :5053-5105 `project_legs_file`, `same_project` | manifests at `$PROJECTS_ROOT/*/project.yaml` and `*/*/project.yaml` | `~/projects/openRepoTools/project.yaml` is depth 1, so a claim on `opensoft/openRepoTools-code#n` from a lane homed at the assembly is "legs of one project.yaml — not a crossing" (:5120-5122). This works with no change. | +| :12555-12595 `tree_id_for`, `lane_tree_put` | sidecar per tree: absolute `path`, `checkout`, branch, head … | Formats are unchanged. Rows written before cutover stay **true**: they describe worktrees of the monorepo history, which is the assembly's own history (FR-001). | +| :12699-12705 `lane_worktree_roots` | `/.claude/worktrees`, `${dir%/*}/.lane-worktrees/` | Same gap as §4.4, proposal 2. | +| :16791-16829 `set-lane-tree` | takes an absolute path and an **unvalidated** `--checkout` | Records a code-leg tree today: `--checkout /code`. | +| :14973-15030 `session-start`, `guard` | the two hook verbs; the guard compares window, session name and register row, never the cwd | unchanged (§4.11) | +| :594 `LANES_LANE_DIR` | leg detection from the lane's checkout | unchanged | + +**Patch:** none. A behaviour change in object keys or roots needs a ruling. **Proposals:** as +in the table. + +### 4.6 `lanes-index` + +It reads the register through `lanes-edit.sh` beside itself (:325) and keeps its store and +config under XDG (`:160-166`). It has no repository-relative path. **No change.** + +### 4.7 `lane-handoff` + +Helpers beside itself (:119-120, :399, :685). The writers poll covers +`$dir/.claude/worktrees/*` and `$parent/.lane-worktrees/$lane/*` (:1892-1996), and has the +same gap as §4.4. The only `openspec/` reference is a comment (:2439). **No change; one +proposal (§4.4, item 2).** + +### 4.8 `lane`, `lanes`, `lane-rename` + +`lanes` with no arguments narrows to "the checkout around the cwd" by its +`git rev-parse --show-toplevel` and that checkout's `origin` (lanes:394-420, lane:440-460). +**Under the triad** a person standing in `code/` gets `--dir /code --repo +opensoft/openRepoTools-code --prefix openRepoTools-code`. No lane matches, and it offers +`lane-start openRepoTools-code 1` (derived). From the assembly root it is unchanged. +`lane-rename` has no path of its own. **Proposal (T018):** when `--show-superproject-working-tree` +is non-empty and that superproject's `project.yaml` names the toplevel as a leg, narrow to the +superproject's directory and origin. This changes today's output inside IRRS/IRSS legs, so it +needs a ruling. Patch sketch: + +```bash +here_root="$(git rev-parse --show-toplevel 2>/dev/null || :)" +super="$(git rev-parse --show-superproject-working-tree 2>/dev/null || :)" +if [ -n "$super" ] && [ -f "$super/project.yaml" ]; then here_root="$super"; fi +``` + +### 4.9 `link-estates` and `repos.tsv` + +`link-estates` finds the workspace through `workspace.yaml` and never from its own location +(:89-295). It links `~/projects//handoffs` → `/handoffs/` for each +estate directory that exists (:399-409), and `~/projects/xFactory/lanes-edit.sh` → +`$OPENREPOTOOLS_BIN_DIR/lanes-edit.sh` (:421). **Under the triad:** +`~/projects/openRepoTools/handoffs` is an untracked symlink in the live checkout today, +and it stays one at the assembly root. The hook link points at the installed copy. **No +change.** + +`repos.tsv`:52 maps `openRepoTools` only. Its own rule (repos.tsv:16-23) is that a row is +added "when a new repo first appears in a LANDING line", resolved with `gh repo view`. Rows +for repositories that do not exist yet would break that rule. **Proposal (cutover, after Gate +C creates the legs):** two rows, `openRepoTools-code opensoft/openRepoTools-code` and +`openRepoTools-spec opensoft/openRepoTools-spec`, in the code leg's `repos.tsv`. + +### 4.10 `openRepoTools --install`, `wip init` and the `installed.tsv` receipt + +**Where each artifact comes from today.** `SELF` is this file (openRepoTools:72). When it is a +file, `selfdir` is its directory. + +| Artifact | Local source (when `SELF` is a file) | Remote source | Code | +| --- | --- | --- | --- | +| The 17 `INSTALLABLES` (openRepoTools, park, resume, status, lane, lanes, lane-handoff, lane-rename, lanes-edit.sh, lane-start, lane-end, link-estates, repos.tsv, claude-current, claude-restart-check, lanes-index, lane-worktrees) | `$selfdir/` | `fetch_from_repo `: `gh api repos/$REPO/contents/?ref=$REF`, else `raw.githubusercontent.com/$REPO/$REF/` | :338, :962-990, :258-275 | +| 3 skills × 2 destinations | `$selfdir/skills//SKILL.md` | `fetch_from_repo skills//SKILL.md` | :1289-1290, :1370-1381 | +| 3 command files × 2 destinations | `$selfdir/commands/.md` | `fetch_from_repo commands/.md` | :1302-1303, :1387-1394 | +| `SessionStart` entry | constant `~/projects/xFactory/lanes-edit.sh session-start \|\| true` | none | :1320, :1324-1333 | +| `UserPromptSubmit` guard | constant `~/projects/xFactory/lanes-edit.sh guard` | none | :1352, :1355-1363 | +| `wip init` pin | `$selfdir/contracts/openreposhape-pin.yaml` | `fetch_from_repo contracts/openreposhape-pin.yaml`, **else the literal `main`** | :2502-2517 | +| `wip init` templates | `$selfdir/upstream/openRepoShape/templates/workspace-root/…` | `gh api`/raw from `opensoft/openRepoShape` at the pin | :2519-2544 | + +**The receipt** (`installed.tsv`, rows written at openRepoTools:561-598) is +` `. It records **no source**, so a code-leg source changes +none of its rows. Readers accept four **or more** fields (`receipt_rows_except` :641-645, +`receipt_verdict` :808-819). A fifth column for the code identity, which T008 asks to be +recorded ("record its actual code identity"), can be appended without breaking an older +reader. + +**What happens in each context once the root split has landed** (derived; the GitHub +behaviour for paths under a gitlink is to be measured in T008): + +| Context | Today | After the split, with no T007 | +| --- | --- | --- | +| a. `code/openRepoTools --install` in an assembly checkout | — | `selfdir` = `code/`, all 23 files local. **Works unchanged.** | +| b. `./openRepoTools --install` at the assembly root | the monorepo file | No file of that name until T007. | +| c. README one-liners (README.md:228, :236) | fetch root `openRepoTools`, which then fetches the rest by root path | The root path `openRepoTools` 404s until T007 exists. With a T007 entry point that only `exec`s the code installer, the code installer still fetches `park`, `skills/…` and the rest from `$REPO@$REF` = the **assembly**, where those paths are under `code` and 404. **The entry point must also hand the delegate one immutable code identity.** | +| d. Installed copy `~/.local/bin/openRepoTools --install` | `selfdir` = `~/.local/bin`, so it copies the **17 already-installed files over themselves** (:976-980; an installed copy never updated its commands) and fetches skills and commands from `opensoft/openRepoTools@main` | `collect_skills` gets a 404 and returns 1. `install_commands` dies at :1125 with "could not fetch one of the 3 skills…", **exit 2, nothing placed**. A safe refusal, but on every workstation. | +| e. Installed copy `openRepoTools wip init` | pin fetched from the monorepo | `contracts/openreposhape-pin.yaml` is not at the assembly root (the assembly has `contracts/{shape,spec,code}-pin.yaml`), so `wip_shape_ref` prints **`main`** (:2516). **Silent drift**: templates come from openRepoShape `main`, not from the pin. | +| f. `--version` | `openRepoTools (opensoft/openRepoTools @ main)` | unchanged text, now naming the assembly | + +**Why no patch made now can save (d) and (e).** The population that would need the fix is +every copy already installed. Those copies get new code only from an install that succeeds, +and the only one that can succeed after cutover is (a) or the T007 one-liner. A +pre-cutover change that probes `contracts/code-pin.yaml` first would add a network request to +every remote install today. It would also have to tell a 404 from a transport failure +(installer-design.md:31-33), which `fetch_from_repo` cannot do: it returns a bare status +(:258-275). That is T007 proper. + +**T007 proposal, as a sketch** (bash 3.2; root file `openRepoTools`; it delegates and adds no +install mechanics): + +```bash +#!/usr/bin/env bash +# openRepoTools (assembly root): resolve ONE code identity, then run the code leg's installer. +set -euo pipefail +REPO="${OPENREPOTOOLS_REPO:-opensoft/openRepoTools}"; REF="${OPENREPOTOOLS_REF:-main}" +SELF="${BASH_SOURCE[0]:-$0}" +die() { printf '\nREFUSED: %s\n' "$1" >&2; exit 2; } +# 1. A local adopted checkout: the gitlink and contracts/code-pin.yaml must agree. +if [ -f "$SELF" ]; then + here="$(cd -- "$(dirname -- "$SELF")" && pwd)" + if [ -f "$here/project.yaml" ]; then + link="$(git -C "$here" ls-tree HEAD code | awk '$2 == "commit" { print $3 }')" + pin="$(sed -n 's/^commit:[[:space:]]*"\{0,1\}\([0-9a-f]\{40\}\)"\{0,1\}[[:space:]]*$/\1/p' "$here/contracts/code-pin.yaml")" + [ -n "$link" ] && [ "$link" = "$pin" ] || die "code gitlink ($link) and contracts/code-pin.yaml ($pin) disagree" + [ "$(git -C "$here/code" rev-parse HEAD 2>/dev/null)" = "$pin" ] || die "code/ is not checked out at the pin $pin: git submodule update --init code" + exec "$here/code/openRepoTools" "$@" + fi +fi +# 2. Remote: REF resolved ONCE; the pin read at that commit; the gitlink checked; the code +# installer run with the public variables naming the code identity, so every payload it +# fetches comes from that one commit (gh first, curl second, as fetch_from_repo does). +commit="$(gh api "repos/$REPO/commits/$REF" --jq .sha)" || die "…" +# … read contracts/code-pin.yaml at $commit (source_repository, commit), check the gitlink +# `gh api repos/$REPO/contents/code?ref=$commit --jq .sha` equals it, fetch +# openRepoTools from $code_repo at $code_commit into a temp file, then: +OPENREPOTOOLS_REPO="$code_repo" OPENREPOTOOLS_REF="$code_commit" exec bash "$tmp" "$@" +``` + +The **code installer** then needs installer-design.md's code-side resolution for context (d), +because an installed copy defaults `REPO` to the assembly again: before `collect_skills`, if +`$REPO@$REF` carries `contracts/code-pin.yaml`, payloads come from the pinned code repository +and commit. A pin that is present but malformed refuses. Only an authoritative 404 means a +legacy monorepo. **Landing order:** the entry point has to be in the root-split PR's tree. If +it lands later, the README one-liners are broken for everyone in between. + +### 4.11 Hooks + +Only `openRepoTools --install` writes hooks: two entries merged into +`$CLAUDE_USER_DIR/settings.json` (`plan_hook_merge` :1760-1880, `place_skill_and_hook` :1976). +Both commands name `~/projects/xFactory/lanes-edit.sh`, which `link-estates` points at the +**installed** `lanes-edit.sh` (link-estates:421). `lane-start` and every other tool write no +hook (grep: comments only). `lanes-edit.sh guard` (:15000-15030) compares window, session +name and register row, never the cwd or the repository layout. **No change.** + +### 4.12 Documents that name checkout paths + +| Where | What | Under the triad | Task | +| --- | --- | --- | --- | +| README.md:157 | `docs/README-lanes.md`, `docs/README-claude-current.md` | From the root README these are `spec/docs/…` | T006 / T014 docs batch | +| README.md:228, :236 | the one-liners for the root `openRepoTools` | stay byte-identical (`RAW_INSTALL_LINE`, test_repo_hygiene.py:105-107, is printed by openRepoShape's `--install`); they need T007 | T007 | +| README.md:417-449, :467-468 | pin-bump procedure with `upstream/openRepoShape`, `contracts/openreposhape-pin.yaml` | From the root these are `code/upstream/…`, `code/contracts/…`, or the procedure moves to the code leg's guidance | T006 | +| AGENTS.md:22, :44 | `docs/README-lanes.md`, `openspec/changes/skip-no-lane-guard/` | `spec/…` from the root | T006 | +| AGENTS.md:200-224, :233, :247-250, :285, :291-292 | `upstream/openRepoShape`, `contracts/openreposhape-pin.yaml`, `git submodule update --init upstream/openRepoShape`, `tests/run.sh`, `.github/workflows/tests.yml`, `tests/test_lane_helpers*.{sh,py}` | All are code-leg paths: from the root, `cd code` first | T006 | +| docs/README-lanes.md:573-594, :3979-3997 | `tests/run.sh` | code-leg path | T006 | +| docs/README-lanes.md:1793 | example `STARTED` line, `dir ~/projects…` | an example; true as long as the assembly stays at its path | none | +| docs/README-claude-current.md:172 | "looks for `claude-current` beside itself" | true in `code/` | none | +| ideation/brainstorm/supervised-context-restart-durable-intent.md:31 | `directory: ~/projects/openRepoTools` (an example intent record) | spec-leg prose; stays true if the assembly keeps that path | none | + +**Hygiene caps that T006 will trip:** test_repo_hygiene.py:1735-1736 caps AGENTS.md at 316 +lines, and it **is** 316. test_repo_hygiene.py:2232-2233 caps README.md at 486, and it **is** +486. The adoption follow-ups "add the line `Read AGENTS-shape.md first…` to the existing +AGENTS.md" (adoption-plan.yaml:339) and "merge shape/README.md into README.md" (:341) each +add lines. Each cap needs a dated entry in the same PR, following the convention the test's +docstring keeps. + +### 4.13 Tests + +Lane 1's **PR #190** (`feat/test-roots-for-the-triad` at `c139e88`, based on `c4864ac`, +T009/T010) is the suite's roots. It was read with `git diff c4864ac..c139e88 -- tests/` and +was not run here. This table says, for each assumption, what #190 does about it. + +| Test | Assumption (`c4864ac`) | Under the triad | Change | +| --- | --- | --- | --- | +| tests/conftest.py:29 | `REPO = parents[1]`, one root | `REPO` is the code root, and stays so | #190: `ROOTS = resolve_roots(os.environ, SUITE_ROOT)`, `REPO = CODE_ROOT`, `ROOTS.path_for(rel)`. The roots come from `OPENREPOTOOLS_{CODE,ASSEMBLY,SPEC}_ROOT`, or are found through `discover_assembly` (`contracts/code-pin.yaml` + `project.yaml` + the gitlink). `OPENREPOTOOLS_COMPOSED=1` is the composed mode. | +| tests/conftest.py:48-58 | `UPSTREAM = REPO/upstream/openRepoShape` | code leg, unchanged (FR-004) | #190: `UPSTREAM = DEPENDENCY_ROOT`. Standalone it skips as today; composed it fails. | +| test_repo_hygiene.py: 23 reads of README.md, AGENTS.md, CLAUDE.md, docs/README-lanes.md (:849, :860, :887, :911, :918, :942, :954, :957, :988, :1010, :1012, :1038, :1048, :1183, :1217, :1221, :1233, :1246, :1735, :2232, :2390, :2624, :3074) | `REPO / …` | README, AGENTS and CLAUDE are assembly files; README-lanes is a spec file | #190: `doc()` and `code_first()` | +| test_repo_hygiene.py:234, :2453, :2650, :2691 | `git ls-files` at REPO | The code leg's own index only | #190: `tracked_files()` over `ROOTS.tracked_roots()`. The composed run also scans the assembly and the spec leg. | +| test_repo_hygiene.py:2681 | `REPO/.gitattributes` carries `* text=auto eol=lf` | **Fails in the code leg**, because the plan puts `.gitattributes` at the root only (adoption-plan.yaml:41-48). Git applies **no** superproject attribute inside a submodule: **measured**, `git -C A/code check-attr -a lane-start` prints nothing while `git -C A check-attr -a project.yaml` prints `text: auto`, `eol: lf`. The installer's `cmp -s` byte checks and test_openrepotools_command.py's byte comparisons depend on that rule. | #190 reads it from the code root **deliberately**, for the same reason. That makes the **T006** proposal mandatory: the code leg carries its own `.gitattributes` (`* text=auto eol=lf`) and `.gitignore` (`__pycache__/`, `*.py[cod]`, `.pytest_cache/`, as at the root today). Without the ignore file, a bare `python3 -m pytest` in `code/` leaves the submodule dirty in the assembly's status. | +| test_openrepotools_command.py:1398 | README.md | assembly | #190: `ROOTS.path_for("README.md")` | +| test_install_*.py, test_wip_init_command.py:42-43, test_upstream_pin.py:28, :102 | `openRepoTools`, `skills/`, `commands/`, `upstream/…`, `.gitmodules`, `contracts/…` at REPO | all code-leg paths | none needed; #190 also edits test_upstream_pin.py | +| tests/test_lane_helpers.sh:50, :3738, :6228 | `SRC_DIR` = repo root; manual read from `$SRC_DIR/docs/README-lanes.md` | The read comes back **empty**. 13 `has` assertions then fail (:3739-3744, :6229-6239, :6580), and 3 `hasnt` assertions pass with nothing to check (:3745, :6241, :6579) | #190: `LANE_MANUAL="${OPENREPOTOOLS_LANE_MANUAL-$SRC_DIR/docs/README-lanes.md}"`. The two quoting blocks are skipped, by name, when it is set and empty. **Not covered:** the third read, inside the `--retire` block (:6579-6580 on `c4864ac`, :6595-6596 in #190), still reads `$ln_doc` after #190's `if`/`else`. With no spec root `ln_doc` is never assigned, and the suite runs under `set -u` (:42), so the whole shell suite **aborts** there with `ln_doc: unbound variable`. **Measured** on the same construct: `bash -c 'set -uo pipefail; has() { :; }; has d "$ln_doc" x; echo after'` prints that error, exits 127 and never reaches `after`. This happens only in a standalone code leg; today's layout is unaffected. | +| tests/test_lane_helpers_suite.py:157-191 | runs the shell suite at REPO | needs the spec root handed to it | #190: `OPENREPOTOOLS_LANE_MANUAL` from `ROOTS.root_of("spec")` | +| tests/run.sh:75-80, :235-236 | cds to the repo root; venv named `openRepoTools` | code root; the venv name is a constant | #190: makes relative `OPENREPOTOOLS_*_ROOT` absolute before the `cd` | + +**One gap in #190 that the lane tooling's layout raises** (read, not run): +`discover_assembly` stops at the first directory above the code checkout that carries the +assembly markers, and requires that directory to mount this checkout. A code worktree in +plan.md's paired layout, `/worktrees//code`, is therefore a leg with no +assembly. Its manual and front-door reads **skip** unless the roots are named, although the +paired spec worktree sits beside it at `../spec`. + +### 4.14 CI (`.github/workflows/tests.yml`) + +Every job checks out **this repository at its root** (`actions/checkout`, :55, :83, :129, +:184, :235, :291), with `submodules: true` (:57, :85, :186, :293) or `false` (:131, :237). +In the code leg the same file runs the code leg's own tests, with the nested +`upstream/openRepoShape` intact. With no spec or assembly context, #190's document reads +skip in a standalone run. Composed (`OPENREPOTOOLS_COMPOSED=1`), the run is refused before its +first test. #190 changes no workflow. The bash-3.2 list (:254-272) and `test_the_macos_job_parses_every_bash_file…` +(test_repo_hygiene.py:166-194) are code-relative and unaffected. + +**T010 proposal, as a sketch:** each job that runs the full suite adds two read-only +checkouts and names them: + +```yaml + - uses: actions/checkout@ + with: { path: code, submodules: true } + - uses: actions/checkout@ + with: { repository: opensoft/openRepoTools-spec, ref: , path: spec } + - uses: actions/checkout@ + with: { repository: opensoft/openRepoTools, ref: main, path: assembly, submodules: false } + - run: bash tests/run.sh -k 'guard_launch_mode or repo_hygiene' + working-directory: code + env: + OPENREPOTOOLS_SPEC_ROOT: ${{ github.workspace }}/spec # #190's names + OPENREPOTOOLS_ASSEMBLY_ROOT: ${{ github.workspace }}/assembly + OPENREPOTOOLS_COMPOSED: "1" +``` + +"Matching" is lane 1's decision: the spec commit the assembly's `contracts/spec-pin.yaml` +names at assembly `main`, or the paired feature branch of the same name. `tests-no-submodule` +(:127-149) keeps proving that a fork's first run skips rather than fails. The assembly's own +`validate.yml` from the shape checks manifest and pins. + +**2026-10-09: the sketch above, as written, cannot pass #190's composed mode** (found by the +review of #190, read here at `dbc6837`). It checks out the PR's code at `code/` and the +assembly at its `main`, and names that assembly in `OPENREPOTOOLS_ASSEMBLY_ROOT` with +`OPENREPOTOOLS_COMPOSED=1`. #190's composed contract refuses any code root the assembly does +not pin: `Roots.absent()` (tests/conftest.py:283) runs `_leg_at_its_pin("code", …)` (:259), +which compares the code root's HEAD with the gitlink the assembly records at its code mount +and refuses a mismatch ("composed acceptance is for the pinned code leg and no other"), and +`pytest_configure` (:404-412) raises that refusal as a `pytest.UsageError`, **exit 4**, before +the first test. On a code PR the PR's commit is never the commit assembly `main` pins, so the +job would exit 4 on every code PR. Without `OPENREPOTOOLS_ASSEMBLY_ROOT`, `discover_assembly` +(tests/conftest.py:159-172) finds an assembly only above the code root that MOUNTS it at its +gitlink, which a sibling `code/` checkout is not. A composed job therefore has to do one of +three things: + +1. check out an assembly whose code gitlink already points at the PR's own commit (a paired + assembly branch that pins it); +2. build a throwaway assembly for the run that pins the PR's commit (the assembly at `main`, + its `code` gitlink and `contracts/code-pin.yaml` moved to the PR's commit, the code leg + checked out as its submodule), and run composed from that; or +3. run #190's **standalone** mode on code PRs (no `OPENREPOTOOLS_COMPOSED`), leaving composed + acceptance to the assembly's own pin-bump PRs. + +Which one is lane 1's decision under §7 item 7; the sketch above is item 2's starting point, +not a job that can run as it stands. + +### 4.15 The register, logs, inventory and handoffs in `brett-wip` (read only) + +`grep -rcE '/workspace/projects/openRepoTools|~/projects/openRepoTools|\$HOME/projects/openRepoTools'` +at brett-wip `c311a78`: + +| File | Hits | What they are | Carry-over proposal | +| --- | ---: | --- | --- | +| lanes/LANES.md | 0 | Rows carry home `opensoft/openRepoTools` | none: the assembly keeps the name | +| lanes/log/openRepoTools-3.md / -1.md / -2.md | 32 / 11 / 11 | `dir ~/projects/openRepoTools` on STARTED/RESUMED/PAUSED lines (29 + 1, 11, 11 of them) | Append-only, never rewritten. They stay valid **if the assembly keeps this path**. If it moves, run one `lane-start --dir openRepoTools ` per lane at its breakpoint; that writes the new `dir`. | +| `~/projects/.lane-state/openRepoTools-{1,2,3}/trees/*.yaml` (beside, not in, brett-wip) | 18 / 8 / 14 sidecars | every row `checkout: ~/projects/openRepoTools`; 19 trees under `.claude/worktrees` | They stay true: they describe monorepo-history worktrees of the same repository. Translate each tree's delta through T016/T017 receipts, then retire it with `lane-worktrees sweep --yes`. The sweep's "landed" test compares against the assembly's default branch, where a monorepo branch's content never lands after the split, so expect **rescue to a bundle**, not "landed". | +| handoffs/openRepoTools/session-handoff-2026-09-13-lane-openRepoTools-3.md, migration-handoff-2026-10-06.md, …-2026-10-02-…-2.md, …-2026-09-16-…-1.md | 56, 42, 26, 24 | historical reads and paths | none: each live lane's next Rule 3 top block restates the current paths | +| handoffs/xFactory/session-handoff-2026-09-05-lane-opsXfactory-1.md | 11 | **"register writes via … `~/projects/openRepoTools/lanes-edit.sh`"**, the **checkout copy** | **Act before cutover:** this lane is live (PAUSED 2026-10-07T11:12:58Z, swap). That path disappears when the live checkout pulls the assembly main. Point its next handoff at the installed copy (`~/.local/bin/lanes-edit.sh` or `~/projects/xFactory/lanes-edit.sh`), and tell its owner. | +| handoffs/xFactory/attachments/opsXfactory-1/land-444.sh | 2 | `LANES_EDIT="${LANES_EDIT:-$HOME/projects/openRepoTools/lanes-edit.sh}"` (:329) | Same. A script that defaults to the checkout copy breaks at the split. | +| handoffs/xFactory/attachments/opsXfactory-1/briefs/DELTA-land-{444-r2,444-r4,444-r5-MERGE-WORKER,462}.md | 1 each | the same checkout-copy path in briefs | same | +| handoffs/xFactory/session-handoff-2026-09-0{2,5}…, -09-10b…, -09-12… (5 files); lanes/log/openxfactory-1.md (4), openXfactory-5.md (1), openXfactory-3.md (1) | 1-4 | mentions of the checkout and of its `lanes-edit.sh` | historical. Check whether any live lane still runs the checkout copy. | +| migration/openRepoTools/prep-20261007T100559Z/{worktrees.tsv, inventory-raw.json, status-openRepoTools.txt}; adopter-fix-20261007-iM20tv/{README.md, logs/lane-worktrees-add.log} | 9, 9, 1; 1, 1 | migration evidence | none: evidence of its time | + +Outside brett-wip, nothing calls a tool by its checkout path. `~/.claude/settings.json` and +the shell rc files have no `projects/openRepoTools` (`grep` exited 1), and neither does +`~/.agents`. In `workBenches` there is one hit, a fixture string at +`devcontainer.test/test-claude-profile-lane-default.sh:282` and `:293`: text a fake prints, +naming a cwd. That is not a tool call. + +--- + +## 5. What does not need to change, and why + +- **How the tools find their helpers** (§4.1): every helper moves to `code/` with the tool. +- **`lane-start`'s directory rungs and `lanes-edit.sh`'s crossing test** (§4.2, §4.5): both + already read `legs:` the way openRepoShape writes them, and the shell suite covers both + (tests/test_lane_helpers.sh:1458-1469 for crossings, :5691-5704 for rung 5). +- **The workspace resolver, the register, logs and handoffs** (`link-estates:89-295`, + byte-identical in four helpers): they are found through `workspace.yaml`, never from a + checkout path. +- **`installed.tsv`**: it records destinations, not sources (§4.10). +- **Both hook entries**: they name the `~/projects/xFactory/lanes-edit.sh` link to the + installed copy (§4.11). +- **`lanes-index`, `lane-rename`, `link-estates`**: no repository-relative path. +- **`set-lane-tree` and the inventory format**: absolute paths. An unvalidated `--checkout` + already admits a code-leg tree. +- **`tests/run.sh`**: the lock, the run root and the venv name are not layout facts. +- **The dependency unit**: `upstream/openRepoShape`, `.gitmodules` and + `contracts/openreposhape-pin.yaml` move together to code, so `test_upstream_pin.py`, + `NEEDS_UPSTREAM` and `wip init`'s local template path stay true there. + +--- + +## 6. What this branch carries + +**One commit: this document** (T006 / T007 / T009 / T010 / T018). No shipped command changed. +Every behaviour change proposed in §4 changes what a command prints or does **today**, for +this repository or for the estate's existing triads. Each is written above as a proposal with +a sketch, for a ruling. + +**2026-10-11: what the branch now ships.** That paragraph described the branch's first +commit. The branch now also carries T018's shape-gated changes (§4.4, 2026-10-09 note): +(a) `lanes-edit.sh project-legs`, the one reader of an assembly's legs, with +`lane-worktrees sweep` and `lane-end`'s gate enumerating the legs, the worktrees they +register and the paired root; (b) `lane-handoff` polling the paired root and recording +each tree under its leg, and `lane-reconcile` reading the legs' registrations and the +paired root (merged from writer 2's `feat/triad-lane-tooling-handoff`); (c) the daily +report counting each leg as a checkout of its own; (d) `lane-worktrees add` recording +the leg it made a tree from. On a single repository every output is byte for byte what +`c45a452` printed, which two stored goldens hold: `tests/fixtures/t018/` and +`tests/fixtures/t018-handoff/`. Usage text moves for every shape (§4.4). The branch merges +main at `f87fd5a` (#190) and changes none of #190's test-root files. + +**2026-10-13: the paired root in `lane-handoff`, after the pre-PR review.** (b) above is +narrower now (the review's M1, option (a), then pending lane 1's ruling on #186; ruled +2026-10-17, §7 item 12). The handoff polls and records a paired tree only where the +lane's inventory names it, its HEAD's own +`Lane:` trailer is the lane's on a commit origin's default branch lacks (the sweep's +rule), or the writers file names it. Every other paired tree is a `PAIRED ROOT, not this +lane's` line in the top block, outside WRITERS, not counted and not recorded. A paired +root, feature directory or leg directory it cannot list or search, and an inventory it +cannot read, are `NOT POLLED` and leave the lane `SWAPPING`, as `lane-reconcile` is +`indeterminate` and the sweep refuses for the same directories. The heading below, +"why this branch carries no test change", means no change to the suite's **roots**: the +branch's own tests are the two modules and goldens named above. + +**Overlap with #190, and why this branch carries no test change.** An earlier draft of this +branch made the suite's roots shape-aware. Lane 1's PR #190 does the same work, more +completely, under other names. The draft was dropped before it was committed; its diff is +kept only in this lane's scratch directory. + +| File | This branch's draft | #190 | Disposition | +| --- | --- | --- | --- | +| tests/conftest.py | `manifest_legs`, `checkout_roots`, `ASSEMBLY`, `SPEC`, `source()`; `$OPENREPOTOOLS_TEST_{ASSEMBLY,SPEC}_ROOT`; a missing root **fails** | `ROOTS`, `resolve_roots`, `ROOTS.path_for`; `$OPENREPOTOOLS_{CODE,ASSEMBLY,SPEC}_ROOT` and `_COMPOSED`; standalone **skips**, composed **refuses** | dropped; #190's definitions stand | +| tests/test_checkout_roots.py (new) | 9 cases on throwaway layouts, the paired layout among them | tests/test_test_roots.py (new, 573 lines) | dropped; the paired layout is a gap in #190 (§4.13) | +| tests/test_repo_hygiene.py | 23 reads through `source()` | `doc()`, `code_first()`, `tracked_files()` | dropped | +| tests/test_openrepotools_command.py | 1 read through `source()` | `ROOTS.path_for("README.md")` | dropped | +| tests/test_lane_helpers.sh, test_lane_helpers_suite.py | `SPEC_DIR` from `$OPENREPOTOOLS_TEST_SPEC_ROOT` (written, never committed) | `LANE_MANUAL` from `$OPENREPOTOOLS_LANE_MANUAL`, with skips | dropped; one read #190 leaves open (§4.13) | + +--- + +## 7. Decisions for lane 1 (the migration owner) + +**2026-10-17: lane 1's rulings on T018.** Lane openRepoTools-1 ruled in comment +**6240800023** on opensoft/openRepoTools#186 ("the four T018 rulings", posted +2026-10-17T20:48Z), answering lane 3's 6240688657, first asked in 6111266988. Each +ruling is quoted at its item below: item 3 (what goes live), item 11 (parked paired +trees), and item 12, added here, for the pre-PR review's M1. The same comment gives +lane 1's word to open the T018 PR once five conditions are met, and lane 1 owns the +landing. + +1. **Bindings (T018):** keep every lane's `dir` at the assembly root and its home at + `opensoft/openRepoTools`, and spell leg objects in full. The alternative is lanes homed on a + leg, which changes the Claude project folder, and with it memory and `--resume`. +2. **Open OpenSpec claims (T018):** re-key `opensoft/openRepoTools:openspec/changes/` to + `opensoft/openRepoTools-spec:…` at cutover, by release and claim. +3. **Worktree visibility (T018):** adopt §4.4 proposal 1 (guidance) now. Rule on proposals 2 + and 3, which change the sweep and the daily report for existing triads too. + **Ruled 2026-10-17 (6240800023, item 3).** Proposal 1, the guidance, goes live now. + Proposals 2 and 3 go live with the T018 PR, on one condition. They change the sweep, + `lane-end`'s gate, the handoff poll, `lane-reconcile`'s TREE rows and the daily report + for every triad on this workstation, and those triads belong to other lanes. So the PR + body must carry a read-only before/after measurement over each of them (IRRS, IRSS, + MedxEHR, MedxGlass, openDox, openXdox, openWallet and the codexFactory copies), + comparing main's tools with T018's. For each triad it lists the sweep rows that + change, every `lane-end` verdict that changes with the lane that owns it, and the + report lines that appear. Any verdict that flips to a refusal is told to its owning + lane, on #186 or in the register, before the landing. +4. **Checkout-copy callers:** opsXfactory-1's handoff and its `land-444.sh` call + `~/projects/openRepoTools/lanes-edit.sh`. Re-point them before the live checkout pulls the + split. +5. **T007 landing order:** the root entry point goes in the root-split PR, and the code + installer's pin resolution covers already-installed re-installs (context d) and + `wip_shape_ref`'s silent `main` (context e). +6. **T006 contents:** the code leg's own `.gitattributes` and `.gitignore`; dated cap entries + for AGENTS.md (316) and README.md (486); the root guidance's `code/` prefixes (§4.12). +7. **T010 context:** which spec and assembly commits a code CI run pairs with, and whether the + code leg's CI carries the two extra checkouts or the assembly's CI runs the composed suite. + **2026-10-09:** the §4.14 sketch exits 4 under #190's composed mode as it stands (#190 at + `dbc6837`: `_leg_at_its_pin`, tests/conftest.py:259, refuses a code root the assembly does + not pin, raised by `pytest_configure`, :404-412; `discover_assembly`, :159-172, finds only + an assembly that mounts the code root). The composed job must check out an assembly whose + code gitlink points at the PR's own commit, or a throwaway assembly built for the run that + pins it, or use #190's standalone mode (§4.14, 2026-10-09 note). +8. **Measure in T008:** what `gh api …/contents/` and `raw.githubusercontent.com` answer + for a path under a gitlink. §4.10 derives a 404 and does not claim it. +9. **Standalone skip, or standalone fail (#190):** there are two readings of FR-008. #190 + skips a document read in a standalone code leg and refuses only in composed mode. That + is sound only if the composed job is **required** on every code PR (T010); otherwise a + code-only CI is green with the manual never read. This branch's dropped draft failed in + both modes, which keeps a code-only CI red until T010 gives it context. Choose one, and + write it into T010. +10. **Paired worktrees in #190:** decide whether `discover_assembly` should also accept + `/worktrees//code`, with the spec root at `../spec` (§4.13), and + move the third manual read (#190's test_lane_helpers.sh:6595-6596) inside its `if`, or + a standalone code-leg run aborts on `ln_doc: unbound variable` (§4.13, measured). +11. **Parked paired trees (T018).** **2026-10-11**, from lane 3's review of #196's T018 + record (review 5481327496 on #196, finding 2), **derived** from the code of this + branch and of workBenches `ffd5516`, not run. The sweep, and so `lane-end`'s + close-out gate, gives a tree under `/worktrees` to a lane only by its + HEAD's own `Lane:` trailer, among the commits origin's default branch lacks: + `lane-worktrees` at 9991d69, :2424-2431 (the paired-root claim) and :4473-4494 + (`own_trailers`, `head_lane_trailer`), and a leg checkout by the same rule, :2124. + workBenches' `park.sh` + (`devBenches/base-image/files/speckit-worktree/templates/specify/extensions/git/scripts/bash/park.sh`) + puts the lane only in the WIP commit's subject, `wip: park — lane ` + (:226), committed with `-m "$WIP_SUBJECT"` and no trailer (:860). `resume.sh` knows + those commits by that subject (:221-231) and `reset --soft HEAD~`s them + (:746), so HEAD is back on the commit below them and the work is uncommitted. A + parked or resumed paired tree is therefore FOREIGN, counted for nobody, and a gate + that passes prints nothing of it: `lane-end`'s pass path (:2122-2127) names only the + unclaimed leg checkouts. It is counted only where the lane's #97 inventory names + it, which happens through `lanes-edit.sh set-lane-tree`, or a `lane-handoff` that + ran while it stood (T018 (b)); `lane-worktrees add` makes its trees under the + lane's own root, never in the paired root. **2026-10-13:** since the handoff polls the + paired root by the sweep's rule (§6, note of that date), it records a parked or + resumed tree only where the inventory or the writers file already names it, so the + handoff no longer closes this gap by itself. Lane 3 changed no attribution code; `docs/README-lanes.md` + (T018 section) says what to check before `lane-end`. **Choose:** (i) the sweep also + reads park's WIP subject as the lane's mark, which couples `lane-worktrees` to a + workBenches subject format; or (ii) park's WIP commit carries a `Lane: ` + trailer, a workBenches change that resume's subject match does not disturb. Either + way a RESUMED tree has no trailer of its own until its writer next commits, because + its HEAD is back below the WIP commits. So decide too whether `resume` records the + tree it rebuilds in the lane's inventory (`lanes-edit.sh set-lane-tree + --checkout /`), which covers both states. + **Ruled 2026-10-17 (6240800023, item 2): (ii), and `resume` records the tree.** + Park's WIP commit carries a `Lane: ` trailer, so attribution stays on one + contract, the trailer, and `lane-worktrees` is not coupled to a workBenches subject + format. `resume` records the tree it rebuilds with `lanes-edit.sh set-lane-tree + --checkout /`, which covers both the parked and the + resumed state. Both are workBenches changes, to be filed as one workBenches issue + that cites this item and is linked from the T018 PR. **workBenches issue: to be + linked.** The PR does not wait for it. Until it lands, `docs/README-lanes.md` (T018 + section) says what to check before `lane-end`. +12. **The paired root in `lane-handoff` (T018, the pre-PR review's M1).** **Ruled + 2026-10-17 (6240800023, item 1): (a), as implemented in `4d4957d`, with the two-lane + regression test** (`test_t018_a_handoff_takes_no_other_lanes_paired_tree`). The + handoff polls and records a paired tree only when one of these holds: this lane's + inventory names it, its HEAD's last `Lane:` trailer is this lane's on a commit + origin's default branch lacks, or the lane's writers file names it. Every other + paired tree is printed as "PAIRED ROOT, not this lane's", and is not counted or + recorded. Lane 1's reason: recording everything is the misattribution that + `.claude/worktrees` already causes in this checkout's directory, where on 2026-10-11 + six other lanes' trees went into lane 1's inventory that way. Option (b), recording + every paired tree as the running lane's, is not taken. diff --git a/lane-end b/lane-end index cde8c280..05ec8b84 100755 --- a/lane-end +++ b/lane-end @@ -208,6 +208,10 @@ # /`, that the table does not name as a tree, scratch or a cache is # RESIDUE - ignored leftovers the sweep's rows never reach - and refuses # even where the sweep answered 0, as a cache row does. +# Where the lane's checkout is an ASSEMBLY (T018, opensoft/openRepoTools#186) +# the sweep also prints a `leg` row per leg checkout: one it counts is named +# as a leg checkout whose remedy is a person's — no sweep acts on a leg — and +# one that holds work and is not this lane's is listed and NOT counted. # `--inventory-only` is the one way past it, and `--sweep` is not built. # # THE HANDOFF FRAGMENT @@ -1971,6 +1975,7 @@ fi gate_holder=""; gate_refused=""; gate_lines=""; gate_named="" gate_trees=0; gate_branches=0; gate_scratch=0; gate_caches=0; gate_residue=0; gate_foreign=0 +gate_legs=0; gate_leg_lines="" # T018: an assembly's leg checkouts, counted and listed gate_person=0 # counted rows `--yes` leaves too: a person's to settle gate_lf=' ' @@ -2012,6 +2017,25 @@ while IFS="$TAB" read -r g_kind g_a g_b g_c g_d g_e g_f; do gate_named="${gate_named}$(gate_real "$g_path")${gate_lf}" gate_caches=$((gate_caches + 1)) gate_lines="${gate_lines} cache ${g_a} ${g_path} (${g_e:-0} bytes)${gate_lf}" ;; + # T018 (opensoft/openRepoTools#186): A LEG CHECKOUT OF AN ASSEMBLY — only + # where the lane's checkout is one; a single repository prints no such row. + # The sweep counts it (`retire`) where its HEAD's own Lane: trailer is this + # lane's and it holds work origin lacks or edits no commit carries, or + # where it could not be read; one that holds work and is not this lane's + # is listed below the table and counted for nobody, as the assembly's own + # checkout is. No sweep acts on one, so its remedy is a person's. + leg) + g_path="$(printf '%b' "${g_c:-}")" + g_head="${g_e:-?}" + if [ "${g_b:-}" = retire ]; then + gate_legs=$((gate_legs + 1)) + gate_lines="${gate_lines} leg ${g_a} ${g_path} [${g_d:-?} ${g_head:0:12}]${gate_lf} ${g_f:-}${gate_lf}" + else + case "${g_a:-}" in + clean | absent) : ;; + *) gate_leg_lines="${gate_leg_lines} leg ${g_a} ${g_path} [${g_d:-?} ${g_head:0:12}]${gate_lf} ${g_f:-}${gate_lf}" ;; + esac + fi ;; esac done <&2 + fi if [ "$inventory_only" = 1 ]; then note "--inventory-only changed nothing: the close-out gate passed, so the lane ends as any other does" fi @@ -2114,6 +2143,10 @@ else *) note "lane $LANE is NOT ended: the close-out gate could not be read — \`$gate_shown --dry-run --porcelain\` exited $gate_rc, and a read that failed is never an answer (Amendment 7(d))." ;; esac if [ -n "$gate_lines" ]; then printf '%s' "$gate_lines" >&2; fi + if [ -n "$gate_leg_lines" ]; then + note "and the assembly's leg checkout(s) below hold work that is not this lane's by its HEAD's own Lane: trailer; they are listed and NOT counted:" + printf '%s' "$gate_leg_lines" >&2 + fi if [ "$gate_foreign" -gt 0 ]; then note "($gate_foreign FOREIGN tree(s) beside these are not this lane's, and were not counted.)" fi @@ -2129,6 +2162,9 @@ else gate_act=0 if [ "$gate_rc" = 3 ]; then gate_act=1; fi if [ "$gate_rc" = 0 ] && [ "$gate_caches" -gt 0 ]; then gate_act=1; fi + # T018: A LEG IS NO SWEEP'S TO RETIRE, so where legs are all that is + # counted the act offered is the person's, not `--yes`. + if [ "$gate_legs" -gt 0 ] && [ $((gate_trees + gate_branches + gate_scratch + gate_caches)) = 0 ]; then gate_act=0; fi if [ "$gate_act" = 1 ]; then note "the one act that retires them (--yes removes only what is on origin or in a bundle, never a live writer's tree):" if [ "$gate_holder" = live ]; then @@ -2143,6 +2179,9 @@ else if [ "$gate_residue" -gt 0 ]; then note "residue is no sweep's to retire: move it to \${XDG_STATE_HOME:-~/.local/state}/openRepoTools/lanes/$LANE/scratch/ or remove it by hand" fi + if [ "$gate_legs" -gt 0 ]; then + note "a leg checkout is the assembly's and no sweep acts on one: commit and push its work from inside it (git -C status, git -C push), then end the lane again" + fi note "or end the lane and leave them where they are, in its inventory, with the reason written down:" note " $prog $LANE$rflag$fflag --inventory-only" exit "$gate_exit" diff --git a/lane-handoff b/lane-handoff index e854cb7a..75b52a37 100755 --- a/lane-handoff +++ b/lane-handoff @@ -1892,6 +1892,8 @@ lifecycle_begin # TWO PLACES, because the estate uses both: `/.claude/worktrees/` # for a writer working inside the lane's own repository, and # `/.lane-worktrees//` for the ones a lane keeps beside it. +# AND A THIRD WHERE THE CHECKOUT IS AN ASSEMBLY (T018, below): the paired +# feature trees `/worktrees//{spec,code}`. # # THE SNAPSHOT FILE IS A PRECONDITION, NOT A CONVENIENCE (Copilot round 1 on # openRepoTools#47). With no temporary file the count below still rises and @@ -1914,10 +1916,49 @@ brief_for() { # } writer_count=0 +pw_withheld_count=0 +# A TREE WITH A `.git` OF ITS OWN THAT GIT CANNOT READ, IN AN ASSEMBLY (T018, +# S2 of the review of bf2a220), is NAMED, never passed over. A leg whose +# module directory (`/.git/modules/`) is damaged takes every +# worktree of its repository with it: the trees `lane-worktrees add --checkout +# /` made, and the paired trees this lane's inventory names. +# Passed over, they vanished from the WRITERS section and from its count while +# the lane went SWAPPED, as if the poll had looked everywhere it looks. So such +# a tree is listed and counted as a writer whose state nobody could read, it is +# NOT recorded (a guessed reading would replace the one its record names), and +# the lane stays SWAPPING for it. Only `poll_writer` calls this, and only where +# the checkout is an assembly: a checkout with no `project.yaml` naming a leg +# passes over such a tree as it always did, which its golden holds. +unreadable_writer() { # + uw_d="$1" + uw_err="$(git -C "$uw_d" rev-parse --git-dir 2>&1 >/dev/null > "$writers_tmp" + if [ -n "$lc_on" ]; then + lc_inventory_missed="${lc_inventory_missed:+$lc_inventory_missed }$uw_d" + note "the worktree inventory entry for $uw_d was NOT written: it has a .git of its own and git could not read it ($uw_err), so neither what it holds nor which checkout it is recorded under is known, and a guessed reading would replace the one its record names now. This handoff's WRITERS section names it, the lane stays SWAPPING for it, and \`$LANES_EDIT lane-reconcile $lane\` says what the inventory holds." + else + note "the tree $uw_d has a .git of its own and git could not read it ($uw_err), so nothing is assumed about it, and this handoff's WRITERS section names it. Read it by hand (git -C $uw_d status) before relaunching a writer onto it." + fi + return 0 +} poll_writer() { # pw_d="$1" [ -d "$pw_d" ] || return 0 - git -C "$pw_d" rev-parse --git-dir >/dev/null 2>&1 || return 0 + if ! git -C "$pw_d" rev-parse --git-dir >/dev/null 2>&1; then + [ -n "$legs_state" ] || return 0 + { [ -e "$pw_d/.git" ] || [ -L "$pw_d/.git" ]; } || return 0 + unreadable_writer "$pw_d" + return 0 + fi # THE OBSERVATION IS THE HELPER'S, AND THIS COMMAND MAKES NONE OF ITS OWN # (Copilot round 5 on openRepoTools#97). `lanes-edit.sh lane-tree-now` is the # one implementation of *what git says about this tree right now* — the same @@ -1962,8 +2003,19 @@ poll_writer() { # [ -n "$pw_status" ] && printf '%s\n' "$pw_status" | sed 's/^/ /' say " git log @{u}..: ${pw_ahead} unpushed" [ -n "$pw_unpushed" ] && printf '%s\n' "$pw_unpushed" | sed 's/^/ /' - printf -- '- `%s` — branch `%s`, last commit `%s`, %s dirty, %s unpushed; brief: %s\n' \ - "$pw_d" "$pw_branch" "${pw_last:-}" "$pw_dirty" "$pw_ahead" \ + # WHERE IT IS RECORDED, decided before its line is written (below), so that a + # tree this handoff withholds says so in its own line — `(NOT recorded)` — + # rather than reading exactly as a recorded one (N1 of the review of + # bf2a220). A checkout with no `project.yaml` naming a leg withholds nothing, + # and its line is the one it always was. + pw_co=""; pw_withheld="" + if [ -n "$lc_on" ] && [ "$pw_orc" = 0 ]; then + pw_co="${dir:-unknown}" + [ -z "$legs_state" ] || pw_co="$(writer_checkout "$pw_d")" + [ -n "$pw_co" ] || pw_withheld=" (NOT recorded)" + fi + printf -- '- `%s`%s — branch `%s`, last commit `%s`, %s dirty, %s unpushed; brief: %s\n' \ + "$pw_d" "$pw_withheld" "$pw_branch" "${pw_last:-}" "$pw_dirty" "$pw_ahead" \ "${pw_brief:-(fill in: the brief this writer was given)}" >> "$writers_tmp" # AND THE SAME OBSERVATION, MACHINE-READABLE, BESIDE THE LANE AND NOT INSIDE # IT (openRepoTools#91). The WRITERS section above is what a person and the @@ -1972,9 +2024,30 @@ poll_writer() { # # handoff's generation and operation, so a tree recorded by a superseded # operation is visible as one. It never fails the poll and never fails the # swap: a lane with no control root simply keeps no inventory yet. + # + # THE CHECKOUT IT IS RECORDED UNDER IS THE LANE'S `dir`, EXCEPT IN AN + # ASSEMBLY (T018): there a tree of a leg's repository is recorded under its + # LEG, the checkout `lane-worktrees add --checkout /code` recorded + # and every reader of the inventory enumerates (`writer_checkout`, below). + # Where no checkout can be vouched for — the assembly's legs could not be + # read, or one of them is unreadable, and the tree is not of the lane's own + # repository nor of a leg git could read — NOTHING is filed: a guessed + # checkout would replace the one `add` recorded, so the tree is named and + # the lane stays SWAPPING for it, as for a refused write. if [ -n "$lc_on" ] && [ "$pw_orc" = 0 ]; then + if [ -n "$pw_withheld" ]; then + pw_withheld_count=$((pw_withheld_count + 1)) + lc_inventory_missed="${lc_inventory_missed:+$lc_inventory_missed }$pw_d" + if [ "$legs_state" = known ]; then + pw_cant="the assembly has $legs_unreadable_count unreadable leg(s), each named by an UNREADABLE LEG line in the top block, so whether this tree is of such a leg's repository" + else + pw_cant="$dir/project.yaml could not be read, so whether this tree is of one of the assembly's legs" + fi + note "the worktree inventory entry for $pw_d was NOT written: $pw_cant — and so which checkout it is recorded under — is not known, and a guessed one would replace the checkout its record names now. This handoff's WRITERS section still names the tree, the lane stays SWAPPING for it, and \`$LANES_EDIT lane-reconcile $lane\` says what the inventory holds." + return 0 + fi LANES_NO_FETCH=1 "$LANES_EDIT" set-lane-tree "$lane" "$pw_d" \ - --checkout "${dir:-unknown}" --branch "$pw_branch" --head "$pw_head" \ + --checkout "$pw_co" --branch "$pw_branch" --head "$pw_head" \ --upstream "$pw_up" --dirty "$pw_dirty" --unpushed "$pw_ahead" \ --writer "${uuid:-none}" --generation "$lc_gen" --operation "$lc_op" \ >/dev/null 2>&1 || { @@ -1989,13 +2062,342 @@ poll_writer() { # return 0 } +# ------------------------------------------------ 3a. A TRIAD'S LEGS (T018) +# +# opensoft/openRepoTools#186, plan task T018. THE LANE'S CHECKOUT IS AN +# ASSEMBLY when its own `project.yaml` declares a leg other than itself — the +# standard's rule, read by `lanes-edit.sh project-legs`, the one reader every +# lane tool asks, never a second grammar here. Its legs (`spec/`, `code/`) are +# submodules, and a feature's trees are worktrees of the LEGS' repositories, so +# two things follow, and neither touches a checkout with no `project.yaml`, +# which is asked nothing and polled and recorded exactly as before: +# +# * THE PAIRED ROOT IS POLLED BY THE SWEEP'S RULE — `/worktrees/ +# ` and `/`, the standard's +# three-leg feature trees. The paired root is per feature and NOT per +# lane: every lane the assembly homes keeps its feature trees there. So a +# tree there is polled and recorded as THIS lane's writer only where one +# of three things holds: this lane's inventory already names it; its +# HEAD's own last `Lane:` trailer is this lane's on a commit origin's +# default branch lacks (`lane-worktrees`' `own_trailers` and +# `head_lane_trailer`, the rule its sweep claims the same root by); or +# the writers file names it, because a writer with nothing committed yet +# has no trailer to be found by. Every other checkout there is ANOTHER +# LANE'S OR NOBODY'S: one informational line in the top block, outside +# WRITERS, never counted and never recorded. A handoff that recorded them +# all made other lanes' feature trees this lane's inventory, so its +# `lane-end` refused on their work, its `sweep --yes` could act on it, and +# the next session was told to relaunch writers into them (the pre-PR +# review of T018, M1, option (a)). Only a directory with a `.git` of its +# own is a checkout there: anything else under it is the assembly's own +# working tree, where git answers too. +# * A TREE IS RECORDED UNDER THE CHECKOUT IT BELONGS TO. A tree of a leg's +# repository — `lane-worktrees add --checkout /code` makes one +# under the lane's own root, and every paired tree is one — was re-recorded +# here with the ASSEMBLY as its checkout, and the leg `add` had recorded +# was lost at the first handoff. Its repository's common git directory, +# which a leg shares with every worktree of it, names its leg now. +# +# A MANIFEST THAT COULD NOT BE READ IS SAID, AND THE SWAP GOES ON +# (`R-A11-11`): the paired root is then NOT polled, the top block's WRITERS +# section says so, no tree is recorded under a checkout nobody could establish, +# and the lane stays SWAPPING — a poll that could not look everywhere is not +# the record a recovery reads as a finished swap. SO IS A PAIRED ROOT, A +# FEATURE DIRECTORY OR A LEG'S DIRECTORY UNDER IT THAT COULD NOT BE LISTED, and +# an inventory that could not be read (`paired_unread`): each is a place a +# tree of this lane's may stand unseen, exactly as `lane-reconcile` says +# `indeterminate` and the sweep refuses for the same directories. +legs_state=""; legs_unread=""; leg_rows=""; leg_commons=""; dir_common=""; dir_phys="" +paired_unread=""; paired_others=""; paired_other_count=0; paired_inv="" +tab="$(printf '\t')" +# THE PHYSICAL PATH OF A CHECKOUT'S COMMON GIT DIRECTORY, or nothing: a leg +# and every worktree of its repository share one (`/.git/modules/ +# `), and git spells it relative where it is the checkout's own `.git`. +common_dir_of() { # + cdo_c="$(git -C "$1" rev-parse --git-common-dir 2>/dev/null)" || return 0 + [ -n "$cdo_c" ] || return 0 + case "$cdo_c" in /*) : ;; *) cdo_c="$1/$cdo_c" ;; esac + ( CDPATH=''; cd -P -- "$cdo_c" 2>/dev/null && pwd -P ) || : + return 0 +} +physical_of() { # + ( CDPATH=''; cd -P -- "$1" 2>/dev/null && pwd -P ) || : + return 0 +} +if [ -n "$dir" ] && { [ -e "$dir/project.yaml" ] || [ -L "$dir/project.yaml" ]; }; then + legs_rc=0 + leg_rows="$(LANES_NO_FETCH=1 "$LANES_EDIT" project-legs "$dir" 2>/dev/null)" || legs_rc=$? + case "$legs_rc" in + 0) [ -z "$leg_rows" ] || legs_state=known ;; + 8) leg_rows="" ;; + *) + leg_rows=""; legs_state=unread + legs_unread="the legs of the assembly $dir could not be read (its project.yaml is there, and \`$LANES_EDIT project-legs $dir\` exited $legs_rc)" + dir_common="$(common_dir_of "$dir")" + note "$legs_unread — which is never 'no legs' (Amendment 7(d)). So the assembly's paired feature trees under $dir/worktrees were NOT polled, and a tree that is not of $dir's own repository is NOT recorded: read them by hand before a writer is relaunched there. The handoff goes on (\`R-A11-11\`), and where it records a lifecycle the lane stays SWAPPING for it." ;; + esac +fi +# EACH LEG THAT IS CHECKED OUT HERE, BY ITS COMMON GIT DIRECTORY. A leg that is +# NOT — a submodule never initialized, or deinitialized — is an empty +# directory of the assembly's, where git answers the ASSEMBLY: its common +# directory would then be the assembly's own, and every tree of the assembly +# would be recorded under that leg. So a leg counts only where git's own +# toplevel there is the leg itself. +# +# A LEG WITH A `.git` OF ITS OWN THAT GIT CANNOT READ is no absent leg: it is a +# read that failed, never an absent or a clean one (Amendment 7(d)). Passed +# over, it was a leg this handoff said nothing of while the sweep counted it, +# and every tree of its repository was re-recorded under the assembly, over +# the leg `add` had recorded. So it is the sweep's rule (`probe_leg`: git's +# toplevel there is not the leg, and a `.git` stands in it), the sweep's word +# and `lane-reconcile`'s: `unreadable`, one `- UNREADABLE LEG` line a leg in the +# top block's WRITERS section and a note on stderr, and COUNTED +# (`legs_unreadable_count`). The lifecycle is NOT left SWAPPING for the leg +# itself, as the reconciliation's verdict is not `indeterminate` for it: the +# poll looked everywhere it looks. A tree this handoff then cannot place is +# another matter (`writer_checkout`): it is named, not recorded, and the lane +# stays SWAPPING for it, as for any tree no checkout can be vouched for. +legs_unreadable=""; legs_unreadable_count=0 +if [ "$legs_state" = known ]; then + while IFS="$tab" read -r lg_repo lg_decl lg_abs; do + [ -n "$lg_abs" ] && [ -d "$lg_abs" ] || continue + lg_top="$(git -C "$lg_abs" rev-parse --show-toplevel 2>/dev/null &1 >/dev/null TAB `, one a line: the top block writes each leg's line + # once the poll has said whether any tree was withheld (N1), and + # `writer_checkout` asks whether a record names one of these legs. + legs_unreadable="$legs_unreadable$lg_abs$tab$lg_why +" + # WHAT IS STILL VOUCHED FOR, AND WHAT COULD BE A LEG'S (`writer_checkout`). + [ -n "$dir_common" ] || dir_common="$(common_dir_of "$dir" + paired_unread="${paired_unread:+$paired_unread; }$1" + note "$1 — a read that failed is never an empty one (Amendment 7(d)). So a tree of this lane's there may be missing from the WRITERS section: read it by hand before a writer is relaunched there. The handoff goes on (\`R-A11-11\`), and where it records a lifecycle the lane stays SWAPPING for it." +} +# THIS LANE'S INVENTORY, READ ONCE, where the checkout is an assembly whose legs +# were read: which paired trees are already this lane's, and the checkout each +# recorded tree was filed under (`writer_checkout`). ` TAB `, one +# a line. A checkout with no `project.yaml` never asks it. +if [ "$legs_state" = known ]; then + pi_out=""; pi_rc=0 + pi_out="$(LANES_NO_FETCH=1 "$LANES_EDIT" lane-trees "$lane" 2>/dev/null = 2 && $2 != "" { print $2 "\t" $10 }')" + pi_bad="$(printf '%s\n' "$pi_out" | awk -F'\037' 'NF >= 2 && $2 == "" { printf "%s ", $1 }')" + [ -z "$pi_bad" ] || + paired_note_unread "this lane's worktree inventory holds record(s) that could not be read (${pi_bad% }), so a paired tree only they name could not be told from another lane's" ;; + 8) : ;; + *) paired_note_unread "this lane's worktree inventory could not be read (\`$LANES_EDIT lane-trees $lane\` exited $pi_rc), so a paired tree only it names could not be told from another lane's" ;; + esac +fi +# THE CHECKOUT THIS LANE'S INVENTORY RECORDS FOR A TREE, under either spelling +# of the tree's path, or nothing. +inventory_checkout() { # + [ -n "$paired_inv" ] || return 1 + ic_r="$(physical_of "$1")" + IC_P="$1" IC_R="$ic_r" awk -F'\t' ' + $1 == ENVIRON["IC_P"] || (ENVIRON["IC_R"] != "" && $1 == ENVIRON["IC_R"]) { print $2; f = 1; exit } + END { exit f ? 0 : 1 }' </code/upstream/`) is no declared leg, and re-recording its +# tree under the assembly would lose the checkout `add` recorded; else the +# lane's own `dir`, as always. Where the legs could not be read, only a tree +# of `dir`'s own repository can be vouched for, and anything else answers +# NOTHING (the caller files nothing). +# +# AND WHERE A LEG IS UNREADABLE (`legs_unreadable_count`), NOTHING IS ANSWERED +# FOR A TREE THAT COULD BE OF THAT LEG'S REPOSITORY, whose common directory +# nobody could read: the lane's `dir` would then replace the leg `add` +# recorded. Such a tree is one whose common git directory nobody could read; +# or one whose common git directory lies physically inside the assembly, or +# inside the assembly's own git directory, without being the assembly's own +# (`/.git/modules/`, a leg's own `.git`, or a linked worktree's +# `modules/` under the common directory); or one this lane's inventory records +# under an unreadable leg, or under a checkout inside one. A tree of ANY OTHER +# repository (a worktree of an unrelated repository in the lane's own root) +# is of no leg, so it keeps the lane's `dir`, exactly as where every leg reads +# (S1 of the review of bf2a220): withheld, it left the lane SWAPPING on every +# handoff and its record going stale for as long as the leg stayed broken. +recorded_under_unreadable_leg() { # + ru_p="$(physical_of "$1")" + while IFS="$tab" read -r ru_abs ru_why; do + [ -n "$ru_abs" ] || continue + case "$1/" in "$ru_abs"/*) return 0 ;; esac + ru_lp="$(physical_of "$ru_abs" + wc_c="$(common_dir_of "$1")" + if [ -n "$wc_c" ] && [ -n "$leg_commons" ]; then + while IFS="$tab" read -r wc_l wc_p; do + if [ -n "$wc_l" ] && [ "$wc_l" = "$wc_c" ]; then printf '%s' "$wc_p"; return 0; fi + done < + [ -n "$writers_file" ] && [ -r "$writers_file" ] || return 1 + awk -F'\t' -v p="$1" '$1 == p { f = 1; exit } END { exit f ? 0 : 1 }' "$writers_file" +} +# EVERY `Lane:` TRAILER of the messages on stdin, one a line, as +# `lane-worktrees` reads them (`^Lane:\s*(\S+)\s*$`, multi-line). +lane_trailers() { + awk '/^Lane:[[:space:]]*[^[:space:]]+[[:space:]]*$/ { + v = $0; sub(/^Lane:[[:space:]]*/, "", v); sub(/[[:space:]]+$/, "", v); print v }' +} +# THE SWEEP'S OWN CLAIM ON THE PAIRED ROOT (`lane-worktrees`' `_lane_claims`, +# with `own_trailers` and `head_lane_trailer`): HEAD's own last `Lane:` +# trailer is this lane's, and this lane's trailer is among the commits HEAD has +# that origin's default branch lacks — `origin/HEAD`, else `origin/main`; with +# neither there is no base to tell them by, and no claim. Compared without +# case, as the sweep compares. A standalone CLONE is never claimed by its +# trailer, as the sweep never adopts one. +head_trailer_is_lane() { # + [ -d "$1/.git" ] && return 1 + ht_base="$(git -C "$1" symbolic-ref -q refs/remotes/origin/HEAD 2>/dev/null || :)" + if [ -z "$ht_base" ] && git -C "$1" rev-parse -q --verify refs/remotes/origin/main >/dev/null 2>&1; then + ht_base=refs/remotes/origin/main + fi + [ -n "$ht_base" ] || return 1 + ht_want="$(printf '%s' "$lane" | tr '[:upper:]' '[:lower:]')" + ht_head="$(git -C "$1" log -1 --format=%B HEAD 2>/dev/null | lane_trailers | tail -n 1 | tr '[:upper:]' '[:lower:]')" + [ -n "$ht_head" ] && [ "$ht_head" = "$ht_want" ] || return 1 + git -C "$1" log --format=%B "$ht_base..HEAD" 2>/dev/null | lane_trailers | tr '[:upper:]' '[:lower:]' | + HT_W="$ht_want" awk '$0 == ENVIRON["HT_W"] { f = 1 } END { exit f ? 0 : 1 }' +} +# ONE CHECKOUT OF THE PAIRED ROOT: polled and recorded as this lane's writer, +# or named once as not this lane's — its branch, its last commit and its HEAD's +# own trailer, which is what a person needs to find its lane — and never +# counted, recorded or offered for a relaunch. +paired_tree() { # + if inventory_checkout "$1" >/dev/null || writers_name "$1" || head_trailer_is_lane "$1"; then + poll_writer "$1" + return 0 + fi + pt_branch="$(git -C "$1" symbolic-ref -q --short HEAD 2>/dev/null)"; pt_brc=$? + case "$pt_brc" in 0) : ;; 1) pt_branch='(detached)' ;; *) pt_branch='?' ;; esac + pt_last="$(git -C "$1" log -1 --format='%h %s' 2>/dev/null || :)" + pt_trailer="$(git -C "$1" log -1 --format=%B HEAD 2>/dev/null | lane_trailers | tail -n 1)" + pt_shown=none + [ -z "$pt_trailer" ] || pt_shown="\`$pt_trailer\`" + paired_other_count=$((paired_other_count + 1)) + say "" + say "PAIRED ROOT, not this lane's: $1 — branch $pt_branch, last commit ${pt_last:-}" + say " HEAD's own Lane: trailer ${pt_trailer:-none}; neither lane $lane's inventory nor its writers file names it, so it is not polled, counted or recorded for $lane" + paired_others="$paired_others$(printf -- '- PAIRED ROOT, not this lane'"'"'s: `%s` — branch `%s`, last commit `%s`, HEAD'"'"'s own `Lane:` trailer %s' \ + "$1" "$pt_branch" "${pt_last:-}" "$pt_shown") +" + return 0 +} + : > "$writers_tmp" if [ -n "$dir" ]; then for w in "$dir"/.claude/worktrees/*; do poll_writer "$w"; done parent="${dir%/*}" for w in "$parent"/.lane-worktrees/"$lane"/*; do poll_writer "$w"; done + # THE PAIRED ROOT, where the checkout is an assembly whose legs were read. + # `}" +[ "$legs_unreadable_count" = 0 ] || + step "legs: $legs_unreadable_count of the assembly's legs under $dir unreadable — named in the top block's WRITERS section, never a clean leg" +[ "$paired_other_count" = 0 ] || + step "paired root: $paired_other_count tree(s) under $dir/worktrees are not lane $lane's — listed in the top block, never counted or recorded" # ------------------------------------------------------ 2. refresh the handoff # @@ -2055,7 +2457,45 @@ top_block() { else cat -- "$writers_tmp" fi + # T018: A POLL THAT COULD NOT LOOK EVERYWHERE SAYS SO HERE, where the next + # session reads it — never only on a stderr nobody kept. + if [ -n "$legs_unread" ]; then + printf -- '- NOT POLLED: %s, so the assembly'"'"'s paired feature trees under `%s/worktrees` are not in this list. Read them by hand before relaunching a writer there.\n' \ + "$legs_unread" "$dir" + fi + if [ -n "$paired_unread" ]; then + printf -- '- NOT POLLED: %s, so a tree of this lane'"'"'s under `%s/worktrees` may be missing from this list. Read it by hand before relaunching a writer there.\n' \ + "$paired_unread" "$dir" + fi + # AND A LEG GIT COULD NOT READ, one line a leg, in the sweep's words. Its + # last clause says what the poll then withheld, and only that (N1 of the + # review of bf2a220): the trees it could not place are each marked + # `(NOT recorded)` above, and where there are none the line says so. + if [ -n "$legs_unreadable" ]; then + if [ "$pw_withheld_count" -gt 0 ]; then + tb_placed="the $pw_withheld_count tree(s) this handoff could not place are named above, each marked (NOT recorded)" + else + tb_placed="every tree this handoff polled could still be placed, so none is withheld for it" + fi + while IFS="$tab" read -r tb_abs tb_why; do + [ -n "$tb_abs" ] || continue + printf -- '- UNREADABLE LEG: `%s` is unreadable, %s. So NOTHING is assumed about it — not its own state, nor which tree is of its repository: %s. A read that failed is never a clean leg (Amendment 7(d)) — read it by hand (`git -C %s status`) before relaunching a writer onto a tree of its repository.\n' \ + "$tb_abs" "$tb_why" "$tb_placed" "$tb_abs" + done </worktrees` - the paired `worktrees//{spec,code}` - as a +root whose trees are the lane's by that same trailer rule. The estate report +counts each leg of every canonical assembly as a checkout of its own - its +branches, root `main` and rescue branches, each row naming its assembly - and +adds a section of the legs holding uncommitted or unpublished work, only where +an assembly's leg is checked out. `add` records a tree made from any checkout +of a leg's repository - the leg itself or a worktree of it - with the LEG as +its checkout, and says, of a tree made from the assembly itself, that its legs +are empty there. A checkout with no `project.yaml` is asked nothing, and its +table, its report and its `add` are what they always were. + Exit codes: 0 dry run: nothing to retire · --yes: every act completed 3 dry run: something to retire (the `lane-end` gate's answer, #163) @@ -1352,6 +1369,7 @@ class Tree: self.reg: dict = {} # its registration, where git has one self.held_back = "" # the bundle-only commit of its untracked files self.lost_bundle = "" # the bundle of commits only its reflog named + self.leg = None # T018: the assembly's leg that registers it @property def slice(self) -> str: @@ -1924,6 +1942,233 @@ def ignored_paths(path: str): return out +# ================================================== an assembly's legs (T018) +# +# opensoft/openRepoTools#186, plan task T018 - A TRIAD'S CHECKOUTS. A checkout +# is an ASSEMBLY when its own `project.yaml` declares a leg other than itself: +# the standard's own rule, read by `lanes-edit.sh project-legs` - the one +# reader every lane tool asks, whose grammar is `lane-start`'s rung 5 line for +# line - and never here a second time. Its legs (`spec/`, `code/`) are +# submodules: their `.git` is a FILE, `git -C worktree list` names +# none of their worktrees, and a feature's paired trees under +# `/worktrees//{spec,code}` are worktrees of the LEGS' +# repositories, so before this every reader below was blind to them. +# +# A CHECKOUT WITH NO `project.yaml` IS ASKED NOTHING: no helper call, no read, +# no row - which is every single-repository checkout, whose sweep, report and +# `add` are therefore byte for byte what they were. + + +class Leg: + """One checkout an assembly mounts: a leg its `project.yaml` declares, or + a dependency NESTED in one - an initialized submodule of the leg's own + (`code/upstream/openRepoShape`). It is the ASSEMBLY'S, shared by every + lane the assembly homes, as the assembly's own checkout is, so a sweep + NEVER acts on the CHECKOUT: it is read - for the worktrees git registers + in it and for its own dirty and unpushed state - and counted for this lane + only where its HEAD's own `Lane:` trailer is this lane's. Its BRANCHES are + another matter: under `--branches` they are rows like the checkout's own + (`_branch_checkouts`), and `--yes` deletes a merged lane branch there + exactly as it does in the checkout, bundling first.""" + + def __init__(self, path: str, decl: str, repository: str, assembly: str, + nested_in: str = ""): + self.path = path + self.real = real(path) + self.decl = decl # its path as declared, under the assembly + self.repository = repository # as the manifest names it ('' when nested) + self.assembly = assembly + self.nested_in = nested_in # the leg it is a dependency of + self.exists = os.path.isdir(path) + self.checkout = False # a checkout of its own is there + self.read_error = "" + self.branch = "" + self.detached = False + self.head = "" + self.dirty = 0 + self.upstream = "" + self.ahead = None + self.on_origin = None + self.origin_url = "" + self.claimed = False # its HEAD's own Lane: trailer is this lane's + self.disposition = "" + self.reason = "" + self.retire = False + + def what(self) -> str: + if self.nested_in: + return f"a dependency nested in the {self.nested_in} leg ({self.decl})" + return (f"the {self.decl} leg of {os.path.join(self.assembly, 'project.yaml')}" + + (f" ({self.repository})" if self.repository else "")) + + def label(self) -> str: + if not self.exists or not self.checkout: + return "(not checked out)" if not self.read_error else "(unreadable)" + if self.detached: + return f"(detached) {short(self.head)}" + if not self.head: + return f"{self.branch} (unborn)" + return f"{self.branch} {short(self.head)}" + + +def project_legs(helper, checkout: str) -> tuple: + """([(repository, path as declared, where it is checked out)], why-not): + the legs `/project.yaml` declares, through `lanes-edit.sh + project-legs`. A checkout with no `project.yaml` is asked nothing; a + manifest that could not be read is never "no legs" (Amendment 7(d)).""" + if not checkout or not os.path.lexists(os.path.join(checkout, "project.yaml")): + return [], "" + proc = helper.call("project-legs", checkout) + if proc.returncode == 8: + return [], "" + if proc.returncode != 0: + return [], (f"`lanes-edit.sh project-legs {checkout}` exited {proc.returncode}: " + + _first_line(proc.stderr)) + rows = [] + for line in proc.stdout.splitlines(): + cols = line.split("\t") + if len(cols) >= 3 and cols[1] and cols[2]: + rows.append((cols[0], cols[1], cols[2])) + return rows, "" + + +def probe_leg(leg: Leg) -> None: + """Is a checkout of its own there? An uninitialized leg is an empty + directory of the assembly's, and git answers the ASSEMBLY there - which is + no checkout of the leg's. A `.git` of its own that git cannot read is a + read that failed, never an absent leg.""" + if not leg.exists: + return + proc = git(leg.path, "rev-parse", "--show-toplevel") + if proc.returncode == 0 and real(proc.stdout.strip()) == leg.real: + leg.checkout = True + elif os.path.lexists(os.path.join(leg.path, ".git")): + leg.read_error = ("it has a .git of its own and git could not read it: " + + (_first_line(proc.stderr) if proc.returncode != 0 else + f"git answers {proc.stdout.strip()} there")) + + +def assembly_checkouts(helper, assembly: str) -> tuple: + """([Leg], declared, why-not) - every leg `/project.yaml` + declares, and every dependency nested in one (the initialized submodules + `git submodule status --recursive` names in each leg), in that order; + `declared` is whether the manifest declares any leg at all, which is what + makes `/worktrees` the paired root.""" + rows, why = project_legs(helper, assembly) + if why: + return [], False, why + out = [] + for repository, decl, path in rows: + leg = Leg(path, decl, repository, assembly) + probe_leg(leg) + out.append(leg) + if not leg.checkout: + continue + sub = git(path, "submodule", "status", "--recursive") + if sub.returncode != 0: + return out, True, (f"the dependencies nested in the {decl} leg ({path}) could not be " + "listed (git submodule status failed): " + _first_line(sub.stderr)) + for line in sub.stdout.splitlines(): + if not line.strip() or line.startswith("-"): + continue # declared and not initialized: nothing to read + parts = line[1:].split(" ", 1) + if len(parts) < 2: + continue + rel = parts[1] + if rel.endswith(")") and " (" in rel: + rel = rel[:rel.rindex(" (")] + nested = Leg(os.path.normpath(os.path.join(path, rel)), f"{decl}/{rel}", "", + assembly, nested_in=decl) + probe_leg(nested) + out.append(nested) + return out, bool(rows), "" + + +def read_leg(leg: Leg, lane: str) -> None: + """What git says about one leg checkout NOW. A read that failed is said, + never converted into a clean-looking value (R22, Amendment 7(d)).""" + if not leg.checkout or leg.read_error: + return + branch = git(leg.path, "symbolic-ref", "-q", "--short", "HEAD") + if branch.returncode == 0: + leg.branch = branch.stdout.strip() + elif branch.returncode == 1: + leg.detached = True + else: + leg.read_error = "HEAD could not be read: " + _first_line(branch.stderr) + return + head = git(leg.path, "rev-parse", "--verify", "-q", "HEAD") + if head.returncode == 0: + leg.head = head.stdout.strip() + elif not leg.branch: + leg.read_error = "HEAD names no commit" + return + status = git(leg.path, "status", "--porcelain=v1", "--ignore-submodules=none", + "--untracked-files=normal") + if status.returncode != 0: + leg.read_error = "git status failed: " + _first_line(status.stderr) + return + leg.dirty = sum(1 for ln in status.stdout.splitlines() if ln.strip()) + leg.origin_url = git_out(leg.path, "config", "--get", "remote.origin.url") or "" + if leg.head: + contains = git(leg.path, "for-each-ref", "--count=1", "--contains", leg.head, + "--format=%(refname)", "refs/remotes/origin/") + if contains.returncode != 0: + leg.read_error = ("whether origin holds its HEAD could not be read: " + + _first_line(contains.stderr)) + return + leg.on_origin = bool(contains.stdout.strip()) + if leg.branch: + up = git(leg.path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}") + leg.upstream = up.stdout.strip() if up.returncode == 0 else "" + if leg.upstream and leg.head: + n = git_out(leg.path, "rev-list", "--count", "@{u}..HEAD") + leg.ahead = int(n) if n and n.isdigit() else None + leg.claimed = bool(lane) and bool(leg.head) and lane.lower() in own_trailers(leg.path) \ + and head_lane_trailer(leg.path).lower() == lane.lower() + + +def decide_leg(leg: Leg) -> None: + """A leg's row. It is COUNTED - the sweep's exit 3, `lane-end`'s gate - + where its HEAD's own `Lane:` trailer is this lane's and it holds work + origin lacks or edits no commit carries, or where it could not be read; + otherwise it is listed and counted for nobody, as the assembly's own + checkout is. No `--yes` acts on one.""" + what = leg.what() + if leg.read_error: + leg.disposition, leg.retire = "unreadable", True + leg.reason = (f"{what}: git could not be read through it ({leg.read_error}); a read that " + "failed is never a clean leg (Amendment 7(d)) - read it by hand: " + f"git -C {leg.path} status") + return + if not leg.exists or not leg.checkout: + leg.disposition, leg.retire = "absent", False + leg.reason = f"{what}: not checked out here, so it holds nothing to read" + return + parts = [] + if leg.dirty: + parts.append(f"{leg.dirty} dirty or untracked path(s)") + if leg.head and leg.on_origin is False: + parts.append("its HEAD carries commit(s) no origin ref holds" + + (f" ({leg.ahead} ahead of {leg.upstream})" if leg.upstream and leg.ahead + else "") + + ("" if leg.origin_url else "; it has no origin remote")) + if not parts: + leg.disposition, leg.retire = "clean", False + leg.reason = (f"{what}: clean, and its HEAD is on origin" if leg.head else + f"{what}: clean, on an unborn branch with nothing committed") + return + leg.disposition = ("dirty+unpushed" if len(parts) == 2 else + "dirty" if leg.dirty else "unpushed") + leg.retire = leg.claimed + leg.reason = what + ": " + "; ".join(parts) + ( + "; its HEAD's own Lane: trailer is this lane's, so it is COUNTED - commit and push from " + "inside it: a sweep never acts on a leg checkout, which is the assembly's" + if leg.claimed else + "; listed and NOT counted - a leg checkout is the assembly's, shared by every lane it " + "homes, and its HEAD's own Lane: trailer is not this lane's") + + # ============================================================ the sweep class Options: @@ -1993,6 +2238,8 @@ class Sweep: self.halt = "" # why no further act is taken self.refused_check = "" # the self-check's refusal: exit 2 self.usage = "" # a usage error found once the trees are known: 64 + self.legs: list = [] # T018: the assembly's leg checkouts, where it is one + self.paired_root = "" # T018: /worktrees, where it is one # ------------------------------------------------------------- reading def gather(self) -> None: @@ -2051,6 +2298,7 @@ class Sweep: self.procs.unknown[:6]) + (" ..." if len(self.procs.unknown) > 6 else "") + "): a tree their command " "line or parent's directory places them in is of unknown liveness and kept") + self._assembly() self._discover() self.usage = self._live_usage() if self.usage: @@ -2066,6 +2314,8 @@ class Sweep: self._fetch_all() for tree in self.trees: read_tree(tree) + for leg in self.legs: + read_leg(leg, self.lane) self._dependents() # EVERY CONTROL ROOT THE WHOLE-ESTATE WALK FOUND IS A PLACE ANOTHER LANE'S # CLAIM MAY BE (#174, Copilot round 2): `host/vendor/.lane-state`, with @@ -2082,6 +2332,26 @@ class Sweep: tree.merge = self.evidence.judge(tree.path, slug_of(tree.origin_url), tree.branch, tree.head) + def _assembly(self) -> None: + """T018: WHERE THE LANE'S CHECKOUT IS AN ASSEMBLY, its legs and the + dependencies nested in them are read, and `/worktrees` - the + paired `worktrees//{spec,code}` the standard's three-leg root + keeps its feature trees in - is a root of the table. A checkout with no + `project.yaml` is asked nothing. A manifest, or a leg's own submodule + list, that could not be read refuses: which checkouts the assembly + holds would be unknown, never none (Amendment 7(d)).""" + d = self.facts.dir + if not d or not os.path.isdir(d): + return + legs, declared, why = assembly_checkouts(self.helper, d) + self.legs = legs + if declared: + self.paired_root = os.path.join(d, "worktrees") + if why: + self.refusal = self.refusal or ( + f"the legs of the assembly {d} could not be read, so which checkouts and " + f"worktrees it holds is unknown (Amendment 7(d)): {why}") + def _dependents(self) -> None: """What leans on the trees. WHERE A REMOVAL WOULD DELETE A DIRECTORY - a clone the table could take, or `--include-scratch` - the whole @@ -2146,7 +2416,7 @@ class Sweep: # A CLONE IS NEVER ADOPTED: a lane creates worktrees, never clones, # so a standalone clone under its root stays FOREIGN - a finding for # a person's word, as #162's first protocol line has it. - if t.owner != "foreign" or t.foreign_kind in ("other-lane", "clone"): + if t.owner != "foreign" or t.foreign_kind in ("other-lane", "clone", "paired-clone"): continue if lane_root and under(t.path, lane_root) and t.real != real(lane_root): t.owner = "lane-root" @@ -2154,6 +2424,14 @@ class Sweep: and self.lane.lower() in own_trailers(t.path) \ and head_lane_trailer(t.path).lower() == self.lane.lower(): t.owner = "trailer" + # T018: THE PAIRED ROOT IS PER FEATURE, NOT PER LANE, exactly as + # `.claude/worktrees` is per checkout - so a tree there is this + # lane's by its HEAD's own trailer, the same rule, and by nothing else. + elif self.paired_root and t.exists and under(t.path, self.paired_root) \ + and t.real != real(self.paired_root) \ + and self.lane.lower() in own_trailers(t.path) \ + and head_lane_trailer(t.path).lower() == self.lane.lower(): + t.owner = "trailer" claimed = [t for t in self.trees if t.owner in ("lane-root", "trailer")] if self.facts.state == "NONE" and claimed: self.refusal = self.refusal or ( @@ -2175,6 +2453,10 @@ class Sweep: def _discover(self) -> None: seen: dict = {} lane_dir = real(self.facts.dir) if self.facts.dir else "" + # T018: A LEG CHECKOUT IS NO TREE OF THE TABLE, as the lane's own + # checkout is none: it is the assembly's, and its own row says what it + # holds. A record naming one is that row's, never a tree to remove. + leg_by_real = {g.real: g for g in self.legs if g.checkout} for rec in self.facts.inventory: path = rec.get("path") or "" if not path or rec.get("schema") not in ("1",): @@ -2188,6 +2470,8 @@ class Sweep: continue if lane_dir and real(path) == lane_dir: continue + if leg_by_real and real(path) in leg_by_real: + continue t = Tree(path, "inventory") t.sidecar = rec t.checkout = rec.get("checkout") if rec.get("checkout") not in ("", "unknown") else "" @@ -2201,6 +2485,13 @@ class Sweep: for t in list(seen.values()): if t.checkout and os.path.isdir(t.checkout) and t.checkout not in checkouts: checkouts.append(t.checkout) + # T018: EVERY LEG'S REGISTRATIONS TOO, LAST, so a tree registered in a + # leg's repository is the leg's: `git -C worktree list` + # names none of them, and a code tree recorded under any other checkout + # was the only one a sweep ever saw. + for g in self.legs: + if g.checkout and g.real not in [real(c) for c in checkouts]: + checkouts.append(g.path) regs_by_real: dict = {} for co in checkouts: regs = worktree_registrations(co) @@ -2216,8 +2507,10 @@ class Sweep: for rp, (co, reg) in regs_by_real.items(): t = seen.get(rp) if t is None: - t = Tree(reg["path"], "foreign", "registered") + t = Tree(reg["path"], "foreign", + "leg" if real(co) in leg_by_real else "registered") seen[t.real] = t + t.leg = leg_by_real.get(real(co)) t.registered = True t.checkout = t.checkout or co t.locked = bool(reg.get("locked")) @@ -2245,8 +2538,64 @@ class Sweep: continue kind = "clone" if os.path.isdir(os.path.join(full, ".git")) else "lane-root" seen[real(full)] = Tree(full, "foreign", kind) + if self.paired_root: + self._paired(seen) self.trees = sorted(seen.values(), key=lambda t: (t.owner != "inventory", t.path)) + def _paired(self, seen: dict) -> None: + """T018: THE ASSEMBLY'S PAIRED FEATURE TREES, `/worktrees/ + /` (and `` itself), that no + registration above named - a registration lost, or a clone where a + worktree belongs. A directory that cannot be listed refuses, as a lane + root does (#170 G1): an unread root is never an empty one. One that can + be listed and not SEARCHED is unread too - what stands in it cannot be + told - and so is a leg's directory under a feature that cannot be + searched: `lane-reconcile` and `lane-handoff` say the same of each.""" + root = self.paired_root + try: + names = sorted(os.listdir(root)) + unsearchable = not os.access(root, os.X_OK) + except (FileNotFoundError, NotADirectoryError): + return + except OSError as exc: + self.refusal = self.refusal or ( + f"the assembly's worktree root {root} could not be listed " + f"({exc.strerror or exc}), so which feature trees stand in it is unknown") + return + if unsearchable: + self.refusal = self.refusal or ( + f"the assembly's worktree root {root} could not be listed (it cannot be " + "searched), so which feature trees stand in it is unknown") + return + decls = [g.decl for g in self.legs if not g.nested_in] + for name in names: + fdir = os.path.join(root, name) + if not os.path.isdir(fdir) or os.path.islink(fdir): + continue + try: + os.listdir(fdir) + why = "" if os.access(fdir, os.X_OK) else "it cannot be searched" + except OSError as exc: + why = exc.strerror or str(exc) + if why: + self.refusal = self.refusal or ( + f"the assembly's feature directory {fdir} could not be listed " + f"({why}), so which trees stand in it is unknown") + continue + for full in [fdir] + [os.path.join(fdir, d) for d in decls]: + if not os.path.isdir(full) or os.path.islink(full) or real(full) in seen: + continue + if not os.access(full, os.X_OK): + self.refusal = self.refusal or ( + f"the assembly's paired tree directory {full} could not be searched, " + "so whether a tree stands in it is unknown") + continue + if not os.path.lexists(os.path.join(full, ".git")): + continue + kind = ("paired-clone" if os.path.isdir(os.path.join(full, ".git")) + else "paired") + seen[real(full)] = Tree(full, "foreign", kind) + def _other_lanes(self, extra: list = ()) -> None: """A FOREIGN tree another lane's inventory names is THAT lane's. @@ -2365,6 +2714,11 @@ class Sweep: if t.owner == "inventory" and t.checkout and os.path.isdir(t.checkout) \ and real(t.checkout) not in [real(c) for c in checkouts]: checkouts.append(t.checkout) + # T018: AND EVERY LEG'S, where the lane's checkout is an assembly: after + # the split a lane's branches are made in its code leg, not in the root. + for g in self.legs: + if g.checkout and g.real not in [real(c) for c in checkouts]: + checkouts.append(g.path) return checkouts def _fetch_all(self) -> None: @@ -2372,6 +2726,7 @@ class Sweep: probes = [t.path if t.exists else t.checkout for t in self.trees] if self.opts.branches: probes += self._branch_checkouts() + probes += [g.path for g in self.legs if g.checkout] # T018 for probe in probes: if not probe or not os.path.isdir(probe): continue @@ -2496,6 +2851,8 @@ class Sweep: # ---------------------------------------------------------- deciding def decide(self) -> None: + for leg in self.legs: + decide_leg(leg) for tree in self.trees: self._decide(tree) if tree.owner in ("lane-root", "trailer") and tree.disposition != "foreign": @@ -2525,6 +2882,8 @@ class Sweep: "clone": "a standalone CLONE under this lane's root (a lane creates " "worktrees, never clones)", "other-lane": f"lane {t.other_lane}'s inventory names it"}.get(t.foreign_kind, "") + if t.foreign_kind in ("leg", "paired", "paired-clone"): + where = self._leg_where(t) put("foreign", f"not in lane {self.lane}'s inventory (#97); {where}", False) if t.lean: t.reason += "; LOAD-BEARING: " + _lean_text(t.lean) @@ -2688,9 +3047,28 @@ class Sweep: if t.remote_tip else f"origin has no {t.remote_branch}") + ": pushed as is, then removed") + def _leg_where(self, t: Tree) -> str: + """T018: where a tree of an assembly's legs stands, for its FOREIGN row.""" + paired = bool(self.paired_root) and under(t.path, self.paired_root) + if t.foreign_kind == "paired-clone": + return ("a standalone CLONE in the assembly's paired feature worktrees " + f"({self.paired_root}); a feature's trees are worktrees of its legs") + if paired: + return (f"in the assembly's paired feature worktrees ({self.paired_root})" + + (f", registered in {t.leg.what()}" if t.leg else "") + + "; its HEAD carries no Lane: trailer of this lane's as its own") + return (f"registered in {t.leg.what() if t.leg else 'a leg of the assembly'}, outside " + "every root a lane's trees are claimed in") + def _branches(self) -> None: + leg_by_real = {g.real: g for g in self.legs if g.checkout} for co in self._branch_checkouts(): items = branch_items(co, self.lane, self.evidence) + leg = leg_by_real.get(real(co)) + if leg is not None: + # T018: WHICH CHECKOUT, since a branch name says nothing of it. + for it in items: + it.detail = f"in {leg.what()}" + (f" · {it.detail}" if it.detail else "") if self.opts.fetch: common = git_out(co, "rev-parse", "--git-common-dir") or "" if common and not os.path.isabs(common): @@ -2851,7 +3229,8 @@ class Sweep: # ------------------------------------------------------------ output def retire_count(self) -> int: return (sum(1 for t in self.trees if t.retire) - + sum(1 for i in self.items if i.retire)) + + sum(1 for i in self.items if i.retire) + + sum(1 for g in self.legs if g.retire)) def render(self) -> None: f = self.facts @@ -2883,6 +3262,14 @@ class Sweep: res = f" -> {t.result}" if t.result else "" say(f" {t.disposition:<22} {t.path}") say(f" {'':<22} {t.label()} · {t.reason}{res}") + if self.legs: + # T018: THE ASSEMBLY'S OWN CHECKOUTS, read and never acted on. + say("") + say(f"LEGS (the assembly's own checkouts, from {os.path.join(f.dir, 'project.yaml')}; " + "read, and never acted on)") + for g in self.legs: + say(f" {g.disposition:<22} {g.path}") + say(f" {'':<22} {g.label()} · {g.reason}") kinds = (("branch", "BRANCHES (--branches)"), ("scratch", "SCRATCH (--include-scratch)"), ("cache", "CACHES (--include-caches)"), ("sandbox", "SANDBOXES (--include-sandboxes)"), ("link", "BROKEN LINKS (--links)")) @@ -2902,6 +3289,8 @@ class Sweep: live = sum(1 for t in self.trees if t.disposition == "live") say(f"SUMMARY: {len(self.trees)} tree(s): {self.retire_count()} to retire · " f"{live} live · {foreign} foreign" + + (f" · {len(self.legs)} leg checkout(s), " + f"{sum(1 for g in self.legs if g.retire)} counted" if self.legs else "") + (f" · archive {tilde(self.archive)}" if self.archive else "")) def porcelain(self) -> None: @@ -2914,6 +3303,9 @@ class Sweep: prow("tree", t.disposition, "retire" if t.retire else "-", t.path, t.branch or ("(detached)" if t.detached else "-"), t.head or "-", t.reason + (f" -> {t.result}" if t.result else "")) + for g in self.legs: # T018 + prow("leg", g.disposition, "retire" if g.retire else "-", g.path, + g.branch or ("(detached)" if g.detached else "-"), g.head or "-", g.reason) for i in self.items: prow(i.kind, i.disposition, "retire" if i.retire else "-", i.path, i.detail or "-", str(i.size), i.reason + (f" -> {i.result}" if i.result else "")) @@ -4887,6 +5279,8 @@ class Report: clones = [r for r in repos if os.path.isdir(os.path.join(r, ".git"))] canonical = self._canonical(clones) self.canonical = canonical + # T018: EVERY CANONICAL ASSEMBLY'S LEGS, each a checkout of its own. + self.legs = self._assembly_legs(canonical) parents = _lane_parents(self.root, canonical) + self._recorded_parents() self.state_dirs = _lane_state_dirs(parents) self.tree_roots = _lane_tree_roots(parents) @@ -4895,6 +5289,8 @@ class Report: self._orphans(closed) self._branches(canonical) self._root_main(canonical) + if self.legs: + self._legs_section() self._aging(canonical) self._caches_and_sandboxes() self._ignored_and_evidence(repos, canonical) @@ -4971,6 +5367,87 @@ class Report: self.duplicates[g] = (keep, len(named) != 1) return sorted(out) + def _assembly_legs(self, canonical: list) -> list: + """T018 (opensoft/openRepoTools#186): THE LEGS OF EVERY CANONICAL + ASSEMBLY - a checkout whose own `project.yaml` declares a leg other than + itself, read by `lanes-edit.sh project-legs`. A leg is a submodule, its + `.git` a FILE, so no leg was ever canonical and the branches all lane + work makes after a split never reached this report. Each is read as a + checkout of its own, its rows naming its assembly. A checkout with no + `project.yaml` is asked nothing, so a report of single repositories is + what it always was; a manifest that could not be read is a row of the + last section.""" + legs = [] + for c in canonical: + rows, why = project_legs(self.helper, c) + if why: + self._unread(c, f"its legs could not be read: {why}", record=False) + continue + for repository, decl, path in rows: + leg = Leg(path, decl, repository, c) + probe_leg(leg) + if leg.checkout or leg.read_error: + legs.append(leg) + return legs + + def _leg_cell(self, path: str) -> str: + """'' for a canonical checkout; for a leg, which assembly it is under - + by its real path too, because `_checkouts` keeps the FIRST spelling of + a checkout that is both, and that is the canonical one.""" + legs = getattr(self, "legs", []) + if not legs: + return "" + for g in legs: + if g.path == path: + return f"the {g.decl} leg of {g.assembly}" + rp = real(path) + for g in legs: + if g.real == rp: + return f"the {g.decl} leg of {g.assembly}" + return "" + + def _checkouts(self, canonical: list) -> list: + """The checkouts the branch, `main` and rescue sections read: every + canonical checkout, then every leg checked out here (T018), EACH ONCE + BY ITS REAL PATH, in that order. A leg whose `.git` is a directory - a + clone, not a submodule's gitfile - is also one of the estate's + canonical checkouts, and read once as each it printed every branch row + and its `main` row twice (the read-only triad measurement of + 2026-10-17, finding 1). With no leg checked out it is `canonical` + itself, untouched, so a report of single repositories cannot move.""" + legs = [g.path for g in getattr(self, "legs", []) if g.checkout] + if not legs: + return list(canonical) + out, seen = [], set() + for c in canonical + legs: + r = real(c) + if r in seen: + continue + seen.add(r) + out.append(c) + return out + + def _legs_section(self) -> None: + """T018: every leg checkout holding edits no commit carries or commits + no origin ref holds - or that could not be read - under its assembly.""" + rows = [] + for g in self.legs: + read_leg(g, "") + decide_leg(g) + if g.disposition in ("clean", "absent"): + continue + state = (g.read_error if g.disposition == "unreadable" else + "; ".join(p for p in ( + f"{g.dirty} dirty or untracked path(s)" if g.dirty else "", + ("HEAD carries commit(s) no origin ref holds" + + (f" ({g.ahead} ahead of {g.upstream})" if g.upstream and g.ahead + else "")) if g.head and g.on_origin is False else "") if p)) + rows.append([g.path, f"the {g.decl} leg of {g.assembly}", g.label(), state]) + self.add("Assembly legs with uncommitted or unpublished work", rows, + "none: every leg checkout is clean and its HEAD is on origin", + "A leg is a checkout of its own under its assembly (T018): it is the " + "assembly's, shared by every lane it homes, and a person's to commit and push.") + def _foreign(self, clones: list, canonical: list) -> None: rows = [] for c in clones: @@ -5050,7 +5527,7 @@ class Report: rows = [] fmt = ("%(refname:short)%1f%(objectname:short=12)%1f%(upstream:short)%1f" "%(upstream:track)%1f%(committerdate:iso-strict)%1f%(contents:trailers)%00") - for c in canonical: + for c in self._checkouts(canonical): proc = git(c, "for-each-ref", f"--format={fmt}", "refs/heads/") if proc.returncode != 0: continue @@ -5078,7 +5555,8 @@ class Report: continue owner = (re.findall(r"^Lane:\s*(\S+)", trailers, re.MULTILINE) or ["-"])[-1] rows.append([f"{c} {name}", tip, state, f"{_age_days(date)} d", - owner, "held by a worktree" if name in held else ""]) + owner, "held by a worktree" if name in held else ""] + + ([self._leg_cell(c)] if self._leg_cell(c) else [])) rows.sort(key=lambda r: -int(r[3].split()[0])) self.add("Unmerged branches with a missing, diverged or unpushed upstream", rows, "none", "Listed, never deleted: a person pushes each to rescue//... " @@ -5086,7 +5564,7 @@ class Report: def _root_main(self, canonical: list) -> None: rows = [] - for c in canonical: + for c in self._checkouts(canonical): if git(c, "rev-parse", "-q", "--verify", "refs/heads/main").returncode != 0 or \ git(c, "rev-parse", "-q", "--verify", "refs/remotes/origin/main").returncode != 0: continue @@ -5100,7 +5578,8 @@ class Report: "only handoff/register paths" if only else f"{len(paths)} path(s)", "Amendment 4: handoffs and the register live in the workspace " "repository and are pushed per write" if only else - "push it through a pull request, or a person's word"]) + "push it through a pull request, or a person's word"] + + ([self._leg_cell(c)] if self._leg_cell(c) else [])) self.add("Root main divergence", rows, "none") def _aging(self, canonical: list) -> None: @@ -5108,7 +5587,7 @@ class Report: rows = [] rescue_count = 0 seen = set() - for c in canonical: + for c in self._checkouts(canonical): proc = git(c, "for-each-ref", "--format=%(refname)\t%(committerdate:iso-strict)", "refs/heads/rescue/", "refs/remotes/origin/rescue/") for line in proc.stdout.splitlines(): @@ -5123,7 +5602,8 @@ class Report: first = name.split("/", 1)[0] if "/" in name else "" owner = first if LANE_SHAPED_RE.match(first) else "-" rows.append([f"{c} rescue/{name}", f"rescue branch, {age} d", - owner, "awaiting disposition"]) + owner, "awaiting disposition"] + + ([self._leg_cell(c)] if self._leg_cell(c) else [])) for d in self.state_dirs: for lane in self._ls(d): for rec in self._tree_records(os.path.join(d, lane, "trees")): @@ -5378,6 +5858,18 @@ def report_all(opts) -> int: SLICE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") +def git_common_dir_of(path: str) -> str: + """The physical path of `path`'s repository's common git directory, or ''. + A leg and every worktree of its repository share one, under the + assembly's `.git/modules/`.""" + common = git_out(path, "rev-parse", "--git-common-dir") + if not common: + return "" + if not os.path.isabs(common): + common = os.path.join(path, common) + return real(common) + + def add_tree(ns) -> int: """`add ` - the one act that MAKES a lane's worktree, and it records the tree in the lane's #97 inventory in the same act @@ -5449,6 +5941,33 @@ def add_tree(ns) -> int: if top is None or real(top) != real(checkout): return refuse(f"{checkout} is not the root of a git checkout" + (f" ({top} is)" if top else "")) + # T018 (opensoft/openRepoTools#186): THE LEG A TREE IS MADE FROM IS WHAT IS + # RECORDED. Where the lane's checkout is an ASSEMBLY, a tree made from one + # of its legs - the leg itself, or any worktree of that leg's repository - + # is recorded with the LEG as its checkout, which every reader of the + # inventory enumerates. A checkout with no `project.yaml` is asked nothing, + # and the record is the checkout given, as always. + record_co, leg_line, assembly_hint = checkout, "", "" + legs, why = project_legs(helper, lane_dir) + if why: + return refuse(f"the legs of the assembly {lane_dir} could not be read, so which " + f"checkout a tree of it belongs to is unknown (Amendment 7(d)): {why}") + if legs: + made_from = git_common_dir_of(checkout) + for repository, decl, lpath in legs: + if made_from and os.path.isdir(lpath) and git_common_dir_of(lpath) == made_from \ + and real(git_out(lpath, "rev-parse", "--show-toplevel") or "") == real(lpath): + record_co = lpath + leg_line = (f" - the {decl} leg of {os.path.join(lane_dir, 'project.yaml')}" + + (f" ({repository})" if repository else "")) + break + if not leg_line and real(checkout) == real(lane_dir): + assembly_hint = (f"lane {lane}'s checkout {lane_dir} is an ASSEMBLY, and this tree is " + "of the assembly itself: a worktree does not check its legs out (" + + ", ".join(f"{d}/" for _r, d, _p in legs) + " are empty in it). A " + "tree of a leg is made from that leg: " + + " or ".join(f"--checkout {os.path.join(lane_dir, d)}" + for _r, d, _p in legs)) # THE RECORD MUST HAVE SOMEWHERE TO GO BEFORE THE TREE IS MADE: a tree # this act could not record is the very thing it exists to stop. tproc = helper.call("lane-trees", lane) @@ -5482,7 +6001,9 @@ def add_tree(ns) -> int: ["worktree", "add", "--no-track", "-b", branch, path, start_oid]) if ns.dry_run: note(f"DRY RUN, nothing was made: git -C {checkout} {' '.join(argv)}; then " - f"lanes-edit.sh set-lane-tree {lane} {path} --checkout {checkout}") + f"lanes-edit.sh set-lane-tree {lane} {path} --checkout {record_co}{leg_line}") + if assembly_hint: + note(assembly_hint) say(path) return 0 try: @@ -5527,7 +6048,7 @@ def add_tree(ns) -> int: return 2 note(f"{what}, and this is left of what this act made: {'; '.join(left)}. Record a " f"tree that is wanted with: lanes-edit.sh set-lane-tree {lane} {path} " - f"--checkout {checkout}") + f"--checkout {record_co}") return 1 made = git(checkout, *argv) @@ -5538,13 +6059,16 @@ def add_tree(ns) -> int: return take_back(f"git worktree add failed: {_first_line(made.stderr)}") head = git_out(path, "rev-parse", "HEAD") or "" caller = os.environ.get("LANES_SESSION") or os.environ.get("CLAUDE_CODE_SESSION_ID") or "" - record = ["set-lane-tree", lane, path, "--checkout", checkout] + record = ["set-lane-tree", lane, path, "--checkout", record_co] if UUID_RE.match(caller): record += ["--writer", caller] rproc = helper.call(*record) if rproc.returncode == 0: - note(f"made {path} on branch {branch} at {short(head)} in {checkout}; recorded in lane " - f"{lane}'s inventory as {rproc.stdout.strip() or '?'}") + note(f"made {path} on branch {branch} at {short(head)} in {checkout}{leg_line}; recorded " + f"in lane {lane}'s inventory as {rproc.stdout.strip() or '?'}" + + (f" with {record_co} as its checkout" if record_co != checkout else "")) + if assembly_hint: + note(assembly_hint) _ACTIVE.pop("add", None) say(path) return 0 diff --git a/lanes-edit.sh b/lanes-edit.sh index e8d58019..0f61abcf 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -360,6 +360,7 @@ # [--dirty ] [--unpushed ] [--writer ] \ # [--generation ] [--operation ] # lanes-edit.sh lane-tree-now +# lanes-edit.sh project-legs # an assembly's legs, by path (T018) # lanes-edit.sh lane-reconcile # lanes-edit.sh worktrees [ | --all] [--index] [--fetch] # lanes-edit.sh pathspec-check ... # opensoft/openRepoTools#162 @@ -5078,6 +5079,70 @@ project_legs_file() { }' "$1" } +# ------------------------------------------- an assembly's legs, by path +# +# opensoft/openRepoTools#186, plan task T018 — WHERE A TRIAD'S LEGS ARE CHECKED +# OUT, for the lane tooling that has to look inside one: `lane-worktrees`' sweep, +# its daily report and its `add`, `lane-handoff`'s writer poll, and +# `lane-reconcile` below. A checkout IS AN ASSEMBLY when its own `project.yaml` +# declares a leg other than itself — the standard's own rule (openRepoShape's +# `templates/assembly-root/project.yaml`: a top-level `legs:` list of `- role:` / +# `repository:` / `path:`, the assembly's own leg at `path: "."`). A checkout +# with no such file, or one naming no leg but itself, is every single +# repository, and every reader asks it nothing more than it did before. +# +# THE READING IS `lane-start`'s RUNG 5, LINE FOR LINE, and never a second +# grammar: rung 5 resolves ONE leg's path for the home it is given, and this +# prints every leg the block declares, as ``. The seven +# lines that decide what a leg is are byte-identical to that reader's, and +# `tests/test_triad_lane_tooling.py` holds them so. As there, ONLY `repository:` +# and `path:` are read, and only as navigation: the manifest CONFERS NOTHING, +# and `role:` is never read. +project_leg_paths_file() { # + [ -f "$1" ] || return 1 + awk ' + /^[A-Za-z_][A-Za-z0-9_]*:/ { inlegs = ($0 ~ /^legs:/); repo = ""; path = ""; next } + !inlegs { next } + /^[ \t]*-[ \t]/ { repo = ""; path = "" } + /^[ \t]+repository:[ \t]*/ { v = $0; sub(/^[ \t]+repository:[ \t]*/, "", v) + sub(/[ \t]+$/, "", v); gsub(/^"|"$/, "", v); gsub(/^'"'"'|'"'"'$/, "", v); repo = v } + /^[ \t]+path:[ \t]*/ { v = $0; sub(/^[ \t]+path:[ \t]*/, "", v) + sub(/[ \t]+$/, "", v); gsub(/^"|"$/, "", v); gsub(/^'"'"'|'"'"'$/, "", v); path = v } + repo != "" && path != "" { print repo "\t" path; repo = ""; path = "" }' "$1" +} + +# THE LEGS OF ONE CHECKOUT, one a line: ``, the last being the declared path +# resolved against the checkout as rung 5 resolves it. The assembly's own `.` +# is left out: it IS the checkout. 0 with legs; 8 with none — no +# `project.yaml`, or one naming no leg but the checkout itself; 1 where the +# manifest is there and could not be read, which is never "no legs" (R22, +# Amendment 7(d)). +assembly_legs() { # + al_dir="${1-}"; al_dir="${al_dir%/}"; al_rows=""; al_out=""; al_tab="$(printf '\t')" + [ -n "$al_dir" ] || return 8 + al_m="$al_dir/project.yaml" + [ -e "$al_m" ] || [ -L "$al_m" ] || return 8 + { [ -f "$al_m" ] && [ -r "$al_m" ]; } || return 1 + al_rows="$(project_leg_paths_file "$al_m")" || return 1 + while IFS="$al_tab" read -r al_repo al_path; do + al_path="${al_path%/}" + case "$al_path" in + ''|.|./.) continue ;; + /*) al_abs="$al_path" ;; + ./*) al_abs="$al_dir/${al_path#./}" ;; + *) al_abs="$al_dir/$al_path" ;; + esac + al_out="$al_out$al_repo$al_tab$al_path$al_tab$al_abs +" + done < # THE LANE'S TWO WORKTREE ROOTS — the same two `lane-handoff` polls, and they # are here rather than there so the poll and the reconciliation cannot come to -# disagree about where a lane keeps its writers. +# disagree about where a lane keeps its writers. Where the lane's checkout is an +# ASSEMBLY (T018) both also read its paired root, `/worktrees/ +# /`, which is two levels deep and per feature, so it +# is read beside these two (`lane_reconcile`, step 4) rather than as a third. lane_worktree_roots() { # lwr_lane="${1-}"; lwr_dir="${2-}" [ -n "$lwr_dir" ] || return 0 @@ -13040,6 +13108,22 @@ EOF # from the two lane roots on disk. It is REPORTED and never adopted, deleted # or overwritten: which lane a tree belongs to is a person's to say. lrc_unmanaged=0 + # A TRIAD (opensoft/openRepoTools#186, plan task T018). WHERE THE LANE'S + # CHECKOUT IS AN ASSEMBLY — its own `project.yaml` declares a leg other than + # itself, read by `assembly_legs`, the one reader every lane tool asks — what + # its LEGS hold is read too: the worktrees git registers in each leg's + # repository, which `git worktree list` in the assembly never names, and the + # paired feature trees `/worktrees//`. + # Both are reported exactly as the checkout's own registrations and the lane + # roots are, and adopted, deleted or overwritten no more. A checkout with no + # `project.yaml` is asked nothing, so its report is what it always was; a + # manifest, a leg's registrations or the paired root that could not be read + # leaves paths nobody read, and the verdict below says so. + lrc_legs=""; lrc_lgrc=8; lrc_legwhy=""; lrc_tab="$(printf '\t')" + if [ -n "$lrc_dir" ] && [ -d "$lrc_dir" ] && { [ -e "$lrc_dir/project.yaml" ] || [ -L "$lrc_dir/project.yaml" ]; }; then + lrc_lgrc=0 + lrc_legs="$(assembly_legs "$lrc_dir")" || lrc_lgrc=$? + fi if [ -n "$lrc_dir" ] && [ -d "$lrc_dir" ]; then # THE CHECKOUT'S OWN ROW IS NOT AN UNMANAGED TREE, AND git ANSWERS WITH THE # PHYSICAL PATH. A recorded `dir` reached through a symlink — which is how @@ -13074,6 +13158,71 @@ EOF done </dev/null || :) EOF + fi + # T018: EVERY LEG'S REGISTRATIONS, beside the checkout's own. A leg that is + # not checked out here is an empty directory of the assembly's, where git + # answers the ASSEMBLY — it registers nothing of its own, so it is passed + # over. A leg's main worktree is listed FIRST and spelled as its git + # directory under the assembly's `.git/modules/`, so it is skipped by place, + # not by name. + # A LEG WITH A `.git` OF ITS OWN THAT GIT CANNOT READ is no absent leg: it is + # a read that failed, never an absent or a clean one (Amendment 7(d)), and + # passed over it was a leg this reconciliation said nothing of while the + # sweep counted it. So it is the sweep's rule (`probe_leg`: git's toplevel + # there is not the leg, and a `.git` stands in it) and the sweep's word: a + # TREE row `unreadable`, which requires recovery, as the sweep's `leg` row is + # `unreadable` and counted. It is NO `indeterminate`: the sweep counts that + # leg and goes on (exit 3), and a verdict it refuses on would hide the count. + if [ "$lrc_lgrc" = 0 ]; then + while IFS="$lrc_tab" read -r lrc_lrepo lrc_ldecl lrc_labs; do + [ -n "$lrc_labs" ] && [ -d "$lrc_labs" ] || continue + lrc_ltop="$(git -C "$lrc_labs" rev-parse --show-toplevel 2>/dev/null &1 >/dev/null /dev/null could + # not be read`), because the sweep reads this verdict FIRST and refuses on + # it: one fact, one spelling, whichever reader a person meets. + if [ "$lrc_lgrc" != 0 ] && [ "$lrc_lgrc" != 8 ] && [ "$lrc_v" != indeterminate ]; then + lrc_v=indeterminate + lrc_why="the legs of the assembly $lrc_dir could not be read (its project.yaml is there and unreadable), so which legs it declares — and the worktrees they register and the paired feature trees under $lrc_dir/worktrees — is NOT established, and no clearance is pronounced over paths nobody read (T018, Amendment 7(d))" + fi + if [ -n "$lrc_legwhy" ] && [ "$lrc_v" != indeterminate ]; then + lrc_v=indeterminate + lrc_why="lane $lrc_lane's checkout $lrc_dir is an assembly and not all of what its legs hold could be read ($lrc_legwhy), so no clearance is pronounced over paths nobody read (T018, Amendment 7(d))" + fi printf 'TREES%s%s inventoried%s%s dirty or unpushed%s%s require recovery%s%s unmanaged or stale\n' \ "$US" "$lrc_n" "$US" "$lrc_dirty" "$US" "$lrc_recover" "$US" "$lrc_unmanaged" printf 'VERDICT%s%s%s%s\n' "$US" "$lrc_v" "$US" "$lrc_why" @@ -13377,7 +13583,10 @@ case "$cmd" in # RETIREMENT refusal (exit 2) naming the two acts that replace it. A # compatibility refusal that only fires where the register is already # reachable is not one. - session-start|guard|lane-groups|next-free|append-row-status) : ;; + # AND `project-legs` (T018) READS ONE `project.yaml` and nothing of the + # workspace, so `lane-worktrees`' daily report can ask it on a + # workstation where no register is configured. + session-start|guard|lane-groups|next-free|append-row-status|project-legs) : ;; *) if [ -z "$LANES_REPO" ] || [ -z "$LANES_DIR" ]; then die "$(lanes_workspace_why)" 1 @@ -17011,6 +17220,30 @@ EOF esac ;; + # opensoft/openRepoTools#186, plan task T018 — THE LEGS A CHECKOUT'S + # `project.yaml` DECLARES, by path: `assembly_legs` above, which is the one + # reader every lane tool asks (`lane-worktrees`' sweep, report and `add`, and + # `lane-handoff`'s writer poll), so no two of them can read a triad two ways. + # It reads one file and touches no workspace, so it answers on a workstation + # with no register configured too (the dispatcher exempts it). + # + # 0 , a leg a line + # 8 no legs: no project.yaml there, or one naming no leg but the checkout itself + # 1 a project.yaml is there and could not be read — which is never "no legs" + # 64 a usage error of this subcommand's own + project-legs) + pla="${1-}"; [ -n "$pla" ] || die "usage: project-legs " 64 + [ "$#" -le 1 ] || die "project-legs takes one directory: project-legs " 64 + case "$pla" in /*) : ;; *) die "project-legs takes an ABSOLUTE path: a relative one means whatever the calling process's directory happens to be, and that is never the checkout being asked about" 64 ;; esac + pla_out=""; pla_rc=0 + pla_out="$(assembly_legs "$pla")" || pla_rc=$? + case "$pla_rc" in + 0) printf '%s\n' "$pla_out" ;; + 8) exit 8 ;; + *) die "${pla%/}/project.yaml is there and could not be read, so which legs that checkout declares is NOT known — which is not the same as none (R22, Amendment 7(d))" 1 ;; + esac + ;; + lane-reconcile) lane="${1-}"; [ -n "$lane" ] || die "usage: lane-reconcile " 64 [ "$#" -le 1 ] || die "lane-reconcile takes one lane: lane-reconcile " 64 @@ -17100,6 +17333,6 @@ EOF ;; *) - die "unknown subcommand '$cmd' (verify-row|set-row-state|append-row-status|replace-in-row|append-session-id|append-line|add-row|retire-rows|archive-rows|migrate-state-cells|commit|rename-lane|log|claim|release|who|history|swapped|session-start|guard|idle-holders|live-holder|window-session|transcript-holders|binding|request-handoff|session-lane|window-lane|lane-dir|lane-profile|lane-agent|lane-transcript|lane-last|workspace-root|last-session|forks|duplicate-holder|workstation|fetch-age|lanes|lane-groups|next-free|sibling-filter|resolve-repo|lane-objects|register-row|register-row-local|retired-identity|canon-lane|resolve-home|managed-projection|lane-state|set-lane-state|lane-trees|set-lane-tree|lane-tree-now|lane-reconcile|lane-holders|legacy-restart-check|publish-handoff|restart-intent|set-restart-intent|worktrees|pathspec-check)" 2 + die "unknown subcommand '$cmd' (verify-row|set-row-state|append-row-status|replace-in-row|append-session-id|append-line|add-row|retire-rows|archive-rows|migrate-state-cells|commit|rename-lane|log|claim|release|who|history|swapped|session-start|guard|idle-holders|live-holder|window-session|transcript-holders|binding|request-handoff|session-lane|window-lane|lane-dir|lane-profile|lane-agent|lane-transcript|lane-last|workspace-root|last-session|forks|duplicate-holder|workstation|fetch-age|lanes|lane-groups|next-free|sibling-filter|resolve-repo|lane-objects|register-row|register-row-local|retired-identity|canon-lane|resolve-home|managed-projection|lane-state|set-lane-state|lane-trees|set-lane-tree|lane-tree-now|project-legs|lane-reconcile|lane-holders|legacy-restart-check|publish-handoff|restart-intent|set-restart-intent|worktrees|pathspec-check)" 2 ;; esac diff --git a/tests/fixtures/t018-handoff/single-repository-decoys.golden b/tests/fixtures/t018-handoff/single-repository-decoys.golden new file mode 100644 index 00000000..e547835c --- /dev/null +++ b/tests/fixtures/t018-handoff/single-repository-decoys.golden @@ -0,0 +1,148 @@ +=== repoD: lane-reconcile before === +exit 0 +--- stdout +ROOT/projects/.lane-state/repoD-1 +STATERUNNINGgeneration 1operation op-seedowner -1111-4180-8180-updated +HOLDERnone +BINDINGhereeagle/none +TREEunmanaged/projects/repoD/worktrees/001-d/specgit registers it in /projects/repoD and no sidecar of this lane names it; it is left exactly as it is +TREEunpushed/projects/.lane-worktrees/repoD-1/depbranch dep head upstream origin/main 0 dirty 1 unpushed; observed 0/1 at +TREES1 inventoried1 dirty or unpushed0 require recovery1 unmanaged or stale +VERDICTungraceful-stopthe lane is recorded RUNNING and no verified holder is live: the session stopped before any handoff began, so nothing was polled, refreshed or recorded — inspect every tree below before relaunching a writer +--- stderr + +=== repoD: lane-handoff === +exit 0 +--- stdout + +WRITER /projects/.lane-worktrees/repoD-1/dep — branch dep, last commit lane work in the dependency + git status --short: 0 dirty + git log @{u}..: 1 unpushed + lane work in the dependency + +READY — restart with: pclaude --lane repoD-1 + record: PAUSED lane:repoD-1 → swap; dir /projects/repoD; workstation Eagle; agent claude; transcript none; kind unknown + agent claude, transcript none (lane-start --agent claude resumes it) + lifecycle: SWAPPED (generation 2, operation ) — lanes-edit.sh lane-reconcile repoD-1 + inventory: 1 worktree(s) recorded — lanes-edit.sh lane-trees repoD-1 +--- stderr +lane-handoff: lane=repoD-1 workstation=Eagle +lanes-edit: LANES_NO_FETCH=1 — not fetching; reading origin/main as the ref already stands here +lane-handoff: lane=repoD-1 workstation=Eagle +lane-handoff: lifecycle: RUNNING -> SWAPPING (generation 2, operation ) +lane-handoff: writers polled: 1 under /projects/repoD +lane-handoff: handoff refreshed: /projects/wip/handoffs/repoD-1.md (+14 lines at the head) +lane-handoff: handoff commit: handoff(repoD-1@Eagle): PAUSED +lane-handoff: handoff pushed to origin/main +lanes-edit: 1 line appended to /projects/wip/lanes/log/repoD-1.md +lanes-edit: committed: LOG(repoD-1@Eagle): PAUSED lane:repoD-1 +lanes-edit: pushed origin/main (attempt 1) +lane-handoff: object log: PAUSED lane:repoD-1 → swap; dir /projects/repoD; workstation Eagle; agent claude; transcript none; kind unknown +lane-handoff: lifecycle: SWAPPING -> SWAPPED (generation 2, operation ) + +=== repoD: lane-handoff: top block === +Lane: repoD-1 (profile unknown, session -1111-4180-8180-) — single-use resume prompt: stamp RESUMED-by before acting (lane-collision-protocol rule 3) + +## RESUME PROMPT — PAUSED (t018 handoff), agent claude, transcript none, kind unknown — the count decides: this handoff cannot know whether the process survived + +**FIRST ACTS, in order.** (1) You are lane `repoD-1`, checkout `/projects/repoD`. Stamp this block RESUMED before anything else (Rule 3, single use) — `lane-start --no-launch ` in this window writes it. (2) Read AGENTS.md, then this block. (3) COUNT THE LIVE WRITERS before relaunching anything — the WRITERS section below is a list to COUNT, not a list to relaunch (Amendment 17 Addendum 1 (i)). ONE WORKTREE, ONE WRITER (k): a worktree is one writer's for as long as that writer is live, a relaunch onto a live writer's worktree is the writers' form of Amendment 18(h)'s second binding, and where you find one made you retire the newer before either commits. THIS HANDOFF CANNOT KNOW WHICH KIND FOLLOWED IT: a plain handoff may be followed by a `/clear` in this process or by a relaunch, so nothing below assumes either — the count is the answer. Older sections below are history. + +**STATE at .** Handed off by `lane-handoff` on Eagle: t018 handoff. Agent `claude`, transcript `none`. Kind `unknown`: the act that follows this record was not this command's to know (`kind unknown`, Addendum 1 (h)) — the restart line below is one of the two, and a `/clear` typed here is the other. + +**WRITERS at ** (1 found under `/projects/repoD`) — FIRST count the live writers: `ListAgents` in Claude, the agent's equivalent elsewhere. A writer still live OWNS its worktree: do not relaunch it; one message telling it that its in-flight call died and who the coordinator now is, is enough. Relaunch only a writer that is NOT live, from where it stands — `git status` and `git log @{u}..` in its worktree, then its brief. WHERE THE COUNT AND THIS LIST DISAGREE, THE COUNT WINS: this list is what the paused session expected, the count is what is true. + +- `/projects/.lane-worktrees/repoD-1/dep` — branch `dep`, last commit ` lane work in the dependency`, 0 dirty, 1 unpushed; brief: (fill in: the brief this writer was given) + +=== repoD: lane-handoff: inventory === +exit 0 +--- stdout +/projects/.lane-worktrees/repoD-1/depdeporigin/main01-1111-4180-8180-/projects/repoD21 +--- stderr + +=== repoD: lane-reconcile after === +exit 0 +--- stdout +ROOT/projects/.lane-state/repoD-1 +STATESWAPPEDgeneration 2operation owner -1111-4180-8180-updated +HOLDERnone +BINDINGfreenone +TREEunmanaged/projects/repoD/worktrees/001-d/specgit registers it in /projects/repoD and no sidecar of this lane names it; it is left exactly as it is +TREEunpushed/projects/.lane-worktrees/repoD-1/depbranch dep head upstream origin/main 0 dirty 1 unpushed; observed 0/1 at +TREES1 inventoried1 dirty or unpushed0 require recovery1 unmanaged or stale +VERDICTresumablethe swap completed and no holder is live: the lane may be resumed once the trees below are read +--- stderr + +=== repoQ: lane-reconcile before === +exit 0 +--- stdout +ROOT/projects/.lane-state/repoQ-1 +STATERUNNINGgeneration 1operation op-seedowner -1111-4180-8180-updated +HOLDERnone +BINDINGhereeagle/none +TREEunmanaged/projects/repoQ/worktrees/001-d/specgit registers it in /projects/repoQ and no sidecar of this lane names it; it is left exactly as it is +TREEunpushed/projects/.lane-worktrees/repoQ-1/depbranch dep head upstream origin/main 0 dirty 1 unpushed; observed 0/1 at +TREES1 inventoried1 dirty or unpushed0 require recovery1 unmanaged or stale +VERDICTungraceful-stopthe lane is recorded RUNNING and no verified holder is live: the session stopped before any handoff began, so nothing was polled, refreshed or recorded — inspect every tree below before relaunching a writer +--- stderr + +=== repoQ: lane-handoff === +exit 0 +--- stdout + +WRITER /projects/.lane-worktrees/repoQ-1/dep — branch dep, last commit lane work in the dependency + git status --short: 0 dirty + git log @{u}..: 1 unpushed + lane work in the dependency + +READY — restart with: pclaude --lane repoQ-1 + record: PAUSED lane:repoQ-1 → swap; dir /projects/repoQ; workstation Eagle; agent claude; transcript none; kind unknown + agent claude, transcript none (lane-start --agent claude resumes it) + lifecycle: SWAPPED (generation 2, operation ) — lanes-edit.sh lane-reconcile repoQ-1 + inventory: 1 worktree(s) recorded — lanes-edit.sh lane-trees repoQ-1 +--- stderr +lane-handoff: lane=repoQ-1 workstation=Eagle +lanes-edit: LANES_NO_FETCH=1 — not fetching; reading origin/main as the ref already stands here +lane-handoff: lane=repoQ-1 workstation=Eagle +lane-handoff: lifecycle: RUNNING -> SWAPPING (generation 2, operation ) +lane-handoff: writers polled: 1 under /projects/repoQ +lane-handoff: handoff refreshed: /projects/wip/handoffs/repoQ-1.md (+14 lines at the head) +lane-handoff: handoff commit: handoff(repoQ-1@Eagle): PAUSED +lane-handoff: handoff pushed to origin/main +lanes-edit: 1 line appended to /projects/wip/lanes/log/repoQ-1.md +lanes-edit: committed: LOG(repoQ-1@Eagle): PAUSED lane:repoQ-1 +lanes-edit: pushed origin/main (attempt 1) +lane-handoff: object log: PAUSED lane:repoQ-1 → swap; dir /projects/repoQ; workstation Eagle; agent claude; transcript none; kind unknown +lane-handoff: lifecycle: SWAPPING -> SWAPPED (generation 2, operation ) + +=== repoQ: lane-handoff: top block === +Lane: repoQ-1 (profile unknown, session -1111-4180-8180-) — single-use resume prompt: stamp RESUMED-by before acting (lane-collision-protocol rule 3) + +## RESUME PROMPT — PAUSED (t018 handoff), agent claude, transcript none, kind unknown — the count decides: this handoff cannot know whether the process survived + +**FIRST ACTS, in order.** (1) You are lane `repoQ-1`, checkout `/projects/repoQ`. Stamp this block RESUMED before anything else (Rule 3, single use) — `lane-start --no-launch ` in this window writes it. (2) Read AGENTS.md, then this block. (3) COUNT THE LIVE WRITERS before relaunching anything — the WRITERS section below is a list to COUNT, not a list to relaunch (Amendment 17 Addendum 1 (i)). ONE WORKTREE, ONE WRITER (k): a worktree is one writer's for as long as that writer is live, a relaunch onto a live writer's worktree is the writers' form of Amendment 18(h)'s second binding, and where you find one made you retire the newer before either commits. THIS HANDOFF CANNOT KNOW WHICH KIND FOLLOWED IT: a plain handoff may be followed by a `/clear` in this process or by a relaunch, so nothing below assumes either — the count is the answer. Older sections below are history. + +**STATE at .** Handed off by `lane-handoff` on Eagle: t018 handoff. Agent `claude`, transcript `none`. Kind `unknown`: the act that follows this record was not this command's to know (`kind unknown`, Addendum 1 (h)) — the restart line below is one of the two, and a `/clear` typed here is the other. + +**WRITERS at ** (1 found under `/projects/repoQ`) — FIRST count the live writers: `ListAgents` in Claude, the agent's equivalent elsewhere. A writer still live OWNS its worktree: do not relaunch it; one message telling it that its in-flight call died and who the coordinator now is, is enough. Relaunch only a writer that is NOT live, from where it stands — `git status` and `git log @{u}..` in its worktree, then its brief. WHERE THE COUNT AND THIS LIST DISAGREE, THE COUNT WINS: this list is what the paused session expected, the count is what is true. + +- `/projects/.lane-worktrees/repoQ-1/dep` — branch `dep`, last commit ` lane work in the dependency`, 0 dirty, 1 unpushed; brief: (fill in: the brief this writer was given) + +=== repoQ: lane-handoff: inventory === +exit 0 +--- stdout +/projects/.lane-worktrees/repoQ-1/depdeporigin/main01-1111-4180-8180-/projects/repoQ21 +--- stderr + +=== repoQ: lane-reconcile after === +exit 0 +--- stdout +ROOT/projects/.lane-state/repoQ-1 +STATESWAPPEDgeneration 2operation owner -1111-4180-8180-updated +HOLDERnone +BINDINGfreenone +TREEunmanaged/projects/repoQ/worktrees/001-d/specgit registers it in /projects/repoQ and no sidecar of this lane names it; it is left exactly as it is +TREEunpushed/projects/.lane-worktrees/repoQ-1/depbranch dep head upstream origin/main 0 dirty 1 unpushed; observed 0/1 at +TREES1 inventoried1 dirty or unpushed0 require recovery1 unmanaged or stale +VERDICTresumablethe swap completed and no holder is live: the lane may be resumed once the trees below are read +--- stderr + diff --git a/tests/fixtures/t018/single-repository.golden b/tests/fixtures/t018/single-repository.golden new file mode 100644 index 00000000..1d02e805 --- /dev/null +++ b/tests/fixtures/t018/single-repository.golden @@ -0,0 +1,297 @@ +=== add --dry-run === +exit 0 +--- stdout +/projects/.lane-worktrees/repoS-1/inv +--- stderr +lane-worktrees: DRY RUN, nothing was made: git -C /projects/repoS worktree add --no-track -b inv /projects/.lane-worktrees/repoS-1/inv ; then lanes-edit.sh set-lane-tree repoS-1 /projects/.lane-worktrees/repoS-1/inv --checkout /projects/repoS + +=== add === +exit 0 +--- stdout +/projects/.lane-worktrees/repoS-1/inv +--- stderr +lane-worktrees: made /projects/.lane-worktrees/repoS-1/inv on branch inv at in /projects/repoS; recorded in lane repoS-1's inventory as + +=== sweep porcelain === +exit 3 +--- stdout +lane repoS-1 RUNNING none - here ungraceful-stop +tree wip-rescue+remove retire /projects/.lane-worktrees/repoS-1/inv inv 1 dirty or untracked path(s): a WIP commit on a rescue branch, pushed, then removed +tree foreign - /projects/elsewhere elsewhere not in lane repoS-1's inventory (#97); registered in the lane's checkout +tree live retire /projects/repoS/.claude/worktrees/tr tr a live writer owns it: not in the inventory and active min ago (foreign trees quiet for 24 h only); not in #97's inventory - the lane's by its branch's Lane: trailer +branch list retire old-work · 1 ahead/0 behind origin/main · last commit ( d) · owner repoS-1 0 unmerged (not a GitHub origin); upstream origin/main is ahead: a person disposes of it (push it to rescue/repoS-1/..., or a word) - never deleted +scratch archive+remove retire /projects/.lane-worktrees/repoS-1/notes-scratch - lane scratch: tar and sha256 under the sweeps directory, then removed +cache remove - /projects/.lane-worktrees/repoS-1/inv/__pycache__ - a dependency cache or bytecode: it holds no work and is rebuilt +summary 4 3 1 1 +--- stderr + +=== sweep table === +exit 3 +--- stdout +lane-worktrees sweep repoS-1 - DRY RUN - nothing is changed; --yes performs it + lane: state RUNNING · holder none · binding here (eagle/none) · verdict ungraceful-stop + checkout: /projects/repoS · inventory 1 tree(s) · origin as of the last fetch + merged means: the register's LANDED line for the branch's PR, or gh's MERGED (gh: LANES_NO_GITHUB=1) + +TREES + wip-rescue+remove /projects/.lane-worktrees/repoS-1/inv + inv · 1 dirty or untracked path(s): a WIP commit on a rescue branch, pushed, then removed + foreign /projects/elsewhere + elsewhere · not in lane repoS-1's inventory (#97); registered in the lane's checkout + live /projects/repoS/.claude/worktrees/tr + tr · a live writer owns it: not in the inventory and active min ago (foreign trees quiet for 24 h only); not in #97's inventory - the lane's by its branch's Lane: trailer + +BRANCHES (--branches) + list old-work + · 1 ahead/0 behind origin/main · last commit ( d) · owner repoS-1 · unmerged (not a GitHub origin); upstream origin/main is ahead: a person disposes of it (push it to rescue/repoS-1/..., or a word) - never deleted + +SCRATCH (--include-scratch) + archive+remove /projects/.lane-worktrees/repoS-1/notes-scratch · + lane scratch: tar and sha256 under the sweeps directory, then removed + +CACHES (--include-caches) + remove /projects/.lane-worktrees/repoS-1/inv/__pycache__ · + a dependency cache or bytecode: it holds no work and is rebuilt + +SUMMARY: 3 tree(s): 4 to retire · 1 live · 1 foreign +--- stderr + +=== lane-reconcile === +exit 0 +--- stdout +ROOT/projects/.lane-state/repoS-1 +STATERUNNINGgeneration 1operation op-seedowner -1111-4180-8180-updated +HOLDERnone +BINDINGhereeagle/none +TREEdirty/projects/.lane-worktrees/repoS-1/invbranch inv head upstream none 1 dirty 0 unpushed; observed 0/0 at ; head was and is +TREEunmanaged/projects/elsewheregit registers it in /projects/repoS and no sidecar of this lane names it; it is left exactly as it is +TREEunmanaged/projects/repoS/.claude/worktrees/trgit registers it in /projects/repoS and no sidecar of this lane names it; it is left exactly as it is +TREES1 inventoried1 dirty or unpushed0 require recovery2 unmanaged or stale +VERDICTungraceful-stopthe lane is recorded RUNNING and no verified holder is live: the session stopped before any handoff began, so nothing was polled, refreshed or recorded — inspect every tree below before relaunching a writer +--- stderr + +=== lane-end refused === +exit 2 +--- stdout +--- stderr +lane-end: in-flight read from lanes/log/repoS-1.md (exact), not from the state cell +lane-end: lane repoS-1 is NOT ended: the close-out gate (opensoft/openRepoTools#163) found what is still the lane's: 2 tree(s) and 1 branch(es) and 1 scratch dir(s) and 1 cache(s) + tree wip-rescue+remove /projects/.lane-worktrees/repoS-1/inv [inv ] + git status --ignored: 1 changed; 0 untracked; 2 ignored (.env __pycache__/) + 1 dirty or untracked path(s): a WIP commit on a rescue branch, pushed, then removed + tree live /projects/repoS/.claude/worktrees/tr [tr ] + git status --ignored: clean, nothing ignored in it either + a live writer owns it: not in the inventory and active min ago (foreign trees quiet for 24 h only); not in #97's inventory - the lane's by its branch's Lane: trailer + branch list old-work · 1 ahead/0 behind origin/main · last commit ( d) · owner repoS-1 + unmerged (not a GitHub origin); upstream origin/main is ahead: a person disposes of it (push it to rescue/repoS-1/..., or a word) - never deleted + scratch archive+remove /projects/.lane-worktrees/repoS-1/notes-scratch ( bytes) + lane scratch: tar and sha256 under the sweeps directory, then removed + cache remove /projects/.lane-worktrees/repoS-1/inv/__pycache__ ( bytes) +lane-end: (1 FOREIGN tree(s) beside these are not this lane's, and were not counted.) +lane-end: the table, which changes nothing: +lane-end: lane-worktrees sweep repoS-1 --branches --include-scratch --include-caches +lane-end: the one act that retires them (--yes removes only what is on origin or in a bundle, never a live writer's tree): +lane-end: lane-worktrees sweep repoS-1 --branches --include-scratch --include-caches --yes +lane-end: (2 row(s) above marked keep, live, load-bearing or list are left by --yes as well: each says why, and settling it is a person's) +lane-end: or end the lane and leave them where they are, in its inventory, with the reason written down: +lane-end: lane-end repoS-1 --inventory-only + +=== lane-end refused: row and log === +| `repoS-1` | harness `-1111-4180-8180-` | Eagle / test / brett | | none | handoffs/repoS-1.md | LIVE · · working | +STARTED — lane repoS-1, session -1111-4180-8180-@Eagle, , lane:repoS-1 → home opensoft/repoS; dir /projects/repoS; host eagle; container none; os linux +=== add (manifest, no leg) === +exit 0 +--- stdout +/projects/.lane-worktrees/repoP-1/w +--- stderr +lane-worktrees: made /projects/.lane-worktrees/repoP-1/w on branch w at in /projects/repoP; recorded in lane repoP-1's inventory as + +=== sweep porcelain (manifest, no leg) === +exit 3 +--- stdout +lane repoP-1 RUNNING none - here ungraceful-stop +tree push+remove retire /projects/.lane-worktrees/repoP-1/w w origin has no w: pushed as is, then removed +summary 1 1 0 0 +--- stderr + +=== lane-handoff === +exit 0 +--- stdout + +WRITER /projects/repoS/.claude/worktrees/tr — branch tr, last commit trailer work + git status --short: 0 dirty + git log @{u}..: 1 unpushed + trailer work + +WRITER /projects/.lane-worktrees/repoS-1/inv — branch inv, last commit inv work + git status --short: 1 dirty + M i.txt + git log @{u}..: 0 unpushed + +READY — restart with: pclaude --lane repoS-1 + record: PAUSED lane:repoS-1 → swap; dir /projects/repoS; workstation Eagle; agent claude; transcript none; kind unknown + agent claude, transcript none (lane-start --agent claude resumes it) + lifecycle: SWAPPED (generation 2, operation ) — lanes-edit.sh lane-reconcile repoS-1 + inventory: 2 worktree(s) recorded — lanes-edit.sh lane-trees repoS-1 +--- stderr +lane-handoff: lane=repoS-1 workstation=Eagle +lanes-edit: LANES_NO_FETCH=1 — not fetching; reading origin/main as the ref already stands here +lane-handoff: lane=repoS-1 workstation=Eagle +lane-handoff: lifecycle: RUNNING -> SWAPPING (generation 2, operation ) +lane-handoff: writers polled: 2 under /projects/repoS +lane-handoff: handoff refreshed: /projects/wip/handoffs/repoS-1.md (+15 lines at the head) +lane-handoff: handoff commit: handoff(repoS-1@Eagle): PAUSED +lane-handoff: handoff pushed to origin/main +lanes-edit: 1 line appended to /projects/wip/lanes/log/repoS-1.md +lanes-edit: committed: LOG(repoS-1@Eagle): PAUSED lane:repoS-1 +lanes-edit: pushed origin/main (attempt 1) +lane-handoff: object log: PAUSED lane:repoS-1 → swap; dir /projects/repoS; workstation Eagle; agent claude; transcript none; kind unknown +lane-handoff: lifecycle: SWAPPING -> SWAPPED (generation 2, operation ) + +=== lane-handoff: top block === +Lane: repoS-1 (profile unknown, session -1111-4180-8180-) — single-use resume prompt: stamp RESUMED-by before acting (lane-collision-protocol rule 3) + +## RESUME PROMPT — PAUSED (t018 handoff), agent claude, transcript none, kind unknown — the count decides: this handoff cannot know whether the process survived + +**FIRST ACTS, in order.** (1) You are lane `repoS-1`, checkout `/projects/repoS`. Stamp this block RESUMED before anything else (Rule 3, single use) — `lane-start --no-launch ` in this window writes it. (2) Read AGENTS.md, then this block. (3) COUNT THE LIVE WRITERS before relaunching anything — the WRITERS section below is a list to COUNT, not a list to relaunch (Amendment 17 Addendum 1 (i)). ONE WORKTREE, ONE WRITER (k): a worktree is one writer's for as long as that writer is live, a relaunch onto a live writer's worktree is the writers' form of Amendment 18(h)'s second binding, and where you find one made you retire the newer before either commits. THIS HANDOFF CANNOT KNOW WHICH KIND FOLLOWED IT: a plain handoff may be followed by a `/clear` in this process or by a relaunch, so nothing below assumes either — the count is the answer. Older sections below are history. + +**STATE at .** Handed off by `lane-handoff` on Eagle: t018 handoff. Agent `claude`, transcript `none`. Kind `unknown`: the act that follows this record was not this command's to know (`kind unknown`, Addendum 1 (h)) — the restart line below is one of the two, and a `/clear` typed here is the other. + +**WRITERS at ** (2 found under `/projects/repoS`) — FIRST count the live writers: `ListAgents` in Claude, the agent's equivalent elsewhere. A writer still live OWNS its worktree: do not relaunch it; one message telling it that its in-flight call died and who the coordinator now is, is enough. Relaunch only a writer that is NOT live, from where it stands — `git status` and `git log @{u}..` in its worktree, then its brief. WHERE THE COUNT AND THIS LIST DISAGREE, THE COUNT WINS: this list is what the paused session expected, the count is what is true. + +- `/projects/repoS/.claude/worktrees/tr` — branch `tr`, last commit ` trailer work`, 0 dirty, 1 unpushed; brief: (fill in: the brief this writer was given) +- `/projects/.lane-worktrees/repoS-1/inv` — branch `inv`, last commit ` inv work`, 1 dirty, 0 unpushed; brief: (fill in: the brief this writer was given) + +=== lane-handoff: inventory === +exit 0 +--- stdout +/projects/.lane-worktrees/repoS-1/invinvnone10-1111-4180-8180-/projects/repoS21 +/projects/repoS/.claude/worktrees/trtrorigin/main01-1111-4180-8180-/projects/repoS21 +--- stderr + +=== sweep porcelain after the handoff === +exit 3 +--- stdout +lane repoS-1 SWAPPED none - free resumable +tree wip-rescue+remove retire /projects/.lane-worktrees/repoS-1/inv inv 1 dirty or untracked path(s): a WIP commit on a rescue branch, pushed, then removed +tree push+remove retire /projects/repoS/.claude/worktrees/tr tr origin has no tr: pushed as is, then removed +tree foreign - /projects/elsewhere elsewhere not in lane repoS-1's inventory (#97); registered in the lane's checkout +branch list retire old-work · 1 ahead/0 behind origin/main · last commit ( d) · owner repoS-1 0 unmerged (not a GitHub origin); upstream origin/main is ahead: a person disposes of it (push it to rescue/repoS-1/..., or a word) - never deleted +scratch archive+remove retire /projects/.lane-worktrees/repoS-1/notes-scratch - lane scratch: tar and sha256 under the sweeps directory, then removed +cache remove - /projects/.lane-worktrees/repoS-1/inv/__pycache__ - a dependency cache or bytecode: it holds no work and is rebuilt +summary 4 3 0 1 +--- stderr + +=== lane-end passes === +exit 0 +--- stdout +## Done (RELEASED, CLAIM-LOST, CLOSED, LANDED) + +- nothing recorded as done in this lane's log + +## Open (CLAIMED, TAKEOVER, OPENED, LANDING, WITHDRAWN) + +- nothing open +--- stderr +lane-end: in-flight read from lanes/log/repoS-2.md (exact), not from the state cell +lane-end: close-out gate: nothing of lane repoS-2's is left on disk (lane-worktrees sweep repoS-2 --branches --include-scratch --include-caches --dry-run answered 0) +lanes-edit: line 6 rewritten in /projects/wip/lanes/LANES.md +lanes-edit: state cell REPLACED: 39 chars → 85 chars +lanes-edit: committed: LANES(repoS-2@Eagle): state · ENDED · lane-end on Eagle: window closing; NOTHING IN FLIGHT +lanes-edit: pushed origin/main (attempt 1) +lane-end: row closed: ENDED +lanes-edit: 1 line appended to /projects/wip/lanes/log/repoS-2.md +lanes-edit: committed: LOG(repoS-2@Eagle): ENDED lane:repoS-2 +lanes-edit: pushed origin/main (attempt 1) +lane-end: object log: ENDED + +=== lane-end passes: row and log === +| `repoS-2` | harness `-1111-4180-8180-` | Eagle / test / brett | | none | handoffs/repoS-2.md | ENDED · · lane-end on Eagle: window closing; NOTHING IN FLIGHT | +ENDED — lane repoS-2, session -1111-4180-8180-@Eagle, , lane:repoS-2 — window closing; NOTHING IN FLIGHT +=== report === +exit 0 +--- stdout +# Estate report - Eagle - + +`lane-worktrees sweep --all --dry-run --report` over `/projects`. It changed nothing. + +| finding | count | +|---|---| +| FOREIGN repositories | 0 | +| Orphaned worktrees of ENDED lanes | 0 | +| Unmerged branches with a missing, diverged or unpushed upstream | 5 | +| Root main divergence | 0 | +| Awaiting disposition (older than 14 days) | 0 | +| Caches and sandboxes ( in all) | 1 | +| Ignored directories over | 0 | +| Evidence-shaped paths inside the estate | 0 | +| Sweep archives past retention (90 days) | 0 | +| Workspace repository hygiene | 1 | +| `status --all` findings (pin drift and tracking are reported as what they are, never counted as dirt) | 1 | +| Records the report could not read | 0 | +| rescue branches (all ages) | 0 | +| the sweeps directory | | + +## FOREIGN repositories + +A lane creates worktrees, never clones. Each is a person's to dispose of. + +_none: every clone is the estate's own checkout_ + +## Orphaned worktrees of ENDED lanes + +_none_ + +## Unmerged branches with a missing, diverged or unpushed upstream + +Listed, never deleted: a person pushes each to rescue//... or gives a word. + +- /projects/repoP w · · no upstream · d · repoP-1 · held by a worktree +- /projects/repoS elsewhere · · unpushed [ahead 1] · d · repoS-9 · held by a worktree +- /projects/repoS inv · · no upstream · d · repoS-1 · held by a worktree +- /projects/repoS old-work · · unpushed [ahead 1] · d · repoS-1 +- /projects/repoS tr · · unpushed [ahead 1] · d · repoS-1 · held by a worktree + +## Root main divergence + +_none_ + +## Awaiting disposition (older than 14 days) + +_none_ + +## Caches and sandboxes ( in all) + +Removed without archiving by `--include-caches` / `--include-sandboxes`. + +- /projects/.lane-worktrees/repoS-1/inv/__pycache__ · cache · + +## Ignored directories over + +_none_ + +## Evidence-shaped paths inside the estate + +One evidence root per repository: ${XDG_STATE_HOME:-$HOME/.local/state}/openRepoTools/evidence//-/. Each is a finding to move. + +_none_ + +## Sweep archives past retention (90 days) + +_none_ + +## Workspace repository hygiene + +- /projects/wip/.gitignore · lacks __pycache__/ *.pyc *.pyo .pytest_cache/ .mypy_cache/ .ruff_cache/ node_modules/ .venv/ venv/ site-packages/ · printf '%s\n' '__pycache__/' '*.pyc' '*.pyo' '.pytest_cache/' '.mypy_cache/' '.ruff_cache/' 'node_modules/' '.venv/' 'venv/' 'site-packages/' >> /projects/wip/.gitignore + +## `status --all` findings (pin drift and tracking are reported as what they are, never counted as dirt) + +- repoS · a fixed finding + +## Records the report could not read + +UNKNOWN IS NEVER ABSENT: a lane snapshot, inventory or tree the report could not read is one whose findings above may be missing. + +_none_ +--- stderr + diff --git a/tests/test_triad_lane_tooling.py b/tests/test_triad_lane_tooling.py new file mode 100644 index 00000000..3b6551e3 --- /dev/null +++ b/tests/test_triad_lane_tooling.py @@ -0,0 +1,967 @@ +# SPDX-License-Identifier: Apache-2.0 +"""T018 (opensoft/openRepoTools#186): the lane tooling sees a TRIAD's trees. + +A lane's checkout is an ASSEMBLY when its own `project.yaml` declares a leg +other than itself - the standard's rule, read by `lanes-edit.sh project-legs` +with `lane-start`'s rung-5 grammar. Its legs (`spec/`, `code/`) are submodules +and its feature trees are worktrees of the LEGS' repositories, under +`/worktrees//{spec,code}` - so before this every lane tool +was blind to them (docs/triad-lane-tooling-analysis.md §4.4). + +TWO THINGS ARE HELD HERE, and the second is the condition of the first: + + * on an assembly built from openRepoShape's OWN template bytes at the + pinned commit (`upstream/openRepoShape/templates/assembly-root/`), with + every leg a real git repository behind a bare local origin and a fake `gh` + that exits 1, the sweep, `lane-end`'s gate (and, in later commits, the + handoff poll, the daily report and `add`) see the legs, their worktrees and + the paired feature trees; + * on a SINGLE REPOSITORY - no `project.yaml`, a submodule that is no leg, and + a `project.yaml` naming no leg but itself - every output of every touched + command is byte for byte what the tools at `c45a452` printed. The "before" + is STORED (`tests/fixtures/t018/single-repository.golden`), generated ONCE + from `c45a452`'s own tools by this module's `--capture` and committed: CI + checks out at depth 1, where `c45a452` is not there to run. It is + normalised for what no two runs share - the bench's path, UTC stamps, + hashes, ages and byte sizes - and for nothing else. + +NOTHING REAL IS TOUCHED: every repository, origin, register, `$HOME` and +state directory is under `tmp_path`, and the network is never reached. +""" + +from __future__ import annotations + +import difflib +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from conftest import NEEDS_UPSTREAM, REPO, UPSTREAM, WINDOWS_SKIP # noqa: E402 + +pytestmark = [WINDOWS_SKIP, pytest.mark.skipif( + shutil.which("git") is None or shutil.which("bash") is None, + reason="the lane tools are bash and Python over git")] + +GOLDEN = Path(__file__).resolve().parent / "fixtures" / "t018" / "single-repository.golden" +#: The assembly is built from the pinned openRepoShape's own template bytes, +#: so every assembly case wears conftest's `NEEDS_UPSTREAM`: standalone it +#: skips where the submodule is not checked out, and composed it FAILS. +TEMPLATE = UPSTREAM / "templates" / "assembly-root" + +UUID = "18018018-1111-4180-8180-180180180001" +#: Every command the bench installs beside each other, as `--install` does. +TOOLS = ("lanes-edit.sh", "lane-worktrees", "lane-end", "lane-handoff", "lane-start", + "repos.tsv") +GATE = ("--branches", "--include-scratch", "--include-caches") +US = "\x1f" + +FAKE_GH = "#!/bin/sh\nprintf 'fake gh: no GitHub here (%s)\\n' \"$*\" >&2\nexit 1\n" +FAKE_TMUX = "#!/bin/sh\nexit 1\n" +FAKE_STATUS = ("#!/bin/sh\nprintf 'status --all: reading every estate:\\n\\n" + "=== status repoS ===\\n - a fixed finding\\n'\nexit 1\n") + + +class Bench: + """One workstation under `root`: a workspace repository with a register, + `$HOME`, the projects directory, bare origins, fakes, and the tools under + test copied side by side into `root/bin`.""" + + def __init__(self, root: Path, tools: Path): + self.root = root + self.home = root / "home" + self.projects = self.home / "projects" + self.remotes = root / "remotes" + self.fake = root / "fakebin" + self.bin = root / "bin" + self.wip = self.projects / "wip" + for d in (self.home, self.projects, self.remotes, self.fake, self.bin, root / "tmp", + root / "sandboxes", self.home / ".agents", self.home / ".claude" / "sessions"): + d.mkdir(parents=True, exist_ok=True) + for name in TOOLS: + shutil.copy2(tools / name, self.bin / name) + (self.bin / name).chmod(0o755) + for name, text in (("gh", FAKE_GH), ("tmux", FAKE_TMUX), ("status", FAKE_STATUS)): + (self.fake / name).write_text(text) + (self.fake / name).chmod(0o755) + (self.home / ".gitconfig").write_text( + "[user]\n\tname = t018\n\temail = t018@example.invalid\n" + "[init]\n\tdefaultBranch = main\n[protocol \"file\"]\n\tallow = always\n" + "[advice]\n\tdetachedHead = false\n") + env = {k: v for k, v in os.environ.items() + if not k.startswith(("LANES_", "LANE_", "CLAUDE_", "WORKBENCHES_", "TMUX", "XDG_", + "PROJECTS_", "GH_", "GIT_", "FAKE_", "OPENREPOTOOLS_", + "PYTEST_")) + and k not in ("AGENT_PROTOCOL_ROOT",)} + system_path = os.pathsep.join(p for p in env.get("PATH", "/usr/bin:/bin").split(os.pathsep) + if p and not any(os.path.exists(os.path.join(p, n)) + for n in TOOLS + ("lanes-index",))) + env.update( + HOME=str(self.home), TMPDIR=str(root / "tmp"), + PATH=os.pathsep.join([str(self.fake), str(self.bin), system_path]), + AGENT_PROTOCOL_ROOT=str(self.home / ".agents"), + XDG_STATE_HOME=str(self.home / ".local" / "state"), + XDG_CONFIG_HOME=str(self.home / ".config"), XDG_CACHE_HOME=str(self.home / ".cache"), + PROJECTS_ROOT=str(self.projects), LANES_WORKSTATION="Eagle", LANES_HOST="eagle", + LANES_OS="linux", LANES_CONTAINER="none", LANES_IN_CONTAINER="0", + LANES_NO_GITHUB="1", LANES_INDEX="off", LANE_WORKTREES_REPORT="off", + LANE_WORKTREES_SANDBOX_ROOTS=str(root / "sandboxes"), + LANE_WORKTREES_STATUS=str(self.fake / "status"), + CLAUDE_CONFIG_DIR=str(self.home / ".claude"), LANES_SESSION=UUID, + GIT_CONFIG_NOSYSTEM="1", OPENREPOTOOLS_BIN_DIR=str(self.bin), LC_ALL="C") + self.env = env + self._seed_workspace() + + # ------------------------------------------------------------ plumbing + def run(self, *argv, cwd=None, env=None, timeout=300) -> subprocess.CompletedProcess: + child = dict(self.env) + child.update(env or {}) + return subprocess.run([str(a) for a in argv], cwd=str(cwd or self.root), env=child, + capture_output=True, text=True, timeout=timeout, + stdin=subprocess.DEVNULL) + + def tool(self, name, *args, cwd=None, env=None) -> subprocess.CompletedProcess: + return self.run(self.bin / name, *args, cwd=cwd, env=env) + + def git(self, *args, cwd=None) -> str: + proc = self.run("git", *args, cwd=cwd) + assert proc.returncode == 0, f"git {' '.join(map(str, args))}: {proc.stderr}" + return proc.stdout.strip() + + def commit(self, cwd: Path, message: str, files: dict | None = None, + lane: str | None = None) -> str: + for rel, text in (files or {}).items(): + path = Path(cwd) / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + self.git("add", "-A", cwd=cwd) + self.git("commit", "-q", "--allow-empty", "-m", + f"{message}\n\nLane: {lane}\n" if lane else message, cwd=cwd) + return self.git("rev-parse", "HEAD", cwd=cwd) + + def origin(self, name: str) -> Path: + bare = self.remotes / f"{name}.git" + self.git("init", "-q", "--bare", "-b", "main", bare) + return bare + + def seeded(self, name: str, files: dict) -> Path: + """A bare origin holding one commit of `files`.""" + bare = self.origin(name) + work = self.root / "seed" / name + self.git("clone", "-q", bare, work) + self.commit(work, f"{name}: seed", files) + self.git("push", "-q", "origin", "main", cwd=work) + return bare + + # ------------------------------------------------------------ register + def _seed_workspace(self) -> None: + bare = self.origin("wip") + self.git("clone", "-q", bare, self.wip) + (self.wip / "lanes" / "log").mkdir(parents=True) + (self.wip / "handoffs").mkdir() + (self.wip / "lanes" / "LANES.md").write_text( + "# LANES.md\n\n| lane | session id | workstation / env / user | started (UTC) " + "| objects owned | handoff path | state |\n|---|---|---|---|---|---|---|\n") + self.git("add", "-A", cwd=self.wip) + self.git("commit", "-q", "-m", "seed", cwd=self.wip) + self.git("push", "-q", "origin", "main", cwd=self.wip) + (self.home / ".agents" / "workspace.yaml").write_text( + f"repository: {bare}\npath: {self.wip}\n") + + def lanes(self, home: str, checkout: Path, *names: str) -> None: + """A register row, a handoff and a STARTED line naming `checkout` for + each lane, pushed in one commit; and a RUNNING #97 snapshot.""" + reg = self.wip / "lanes" / "LANES.md" + text = reg.read_text() + for lane in names: + text += (f"| `{lane}` | harness `{UUID}` | Eagle / test / brett | 2026-10-08T00:00Z " + f"| none | handoffs/{lane}.md | LIVE · 2026-10-08T00:00:00Z · working |\n") + (self.wip / "handoffs" / f"{lane}.md").write_text(f"# handoff {lane}\n\nwork.\n") + (self.wip / "lanes" / "log" / f"{lane}.md").write_text( + f"STARTED — lane {lane}, session {UUID}@Eagle, 2026-10-08T00:00:00Z, lane:{lane} " + f"→ home {home}; dir {checkout}; host eagle; container none; os linux\n") + reg.write_text(text) + self.git("add", "-A", cwd=self.wip) + self.git("commit", "-q", "-m", "lanes", cwd=self.wip) + self.git("push", "-q", "origin", "main", cwd=self.wip) + for lane in names: + proc = self.tool("lanes-edit.sh", "set-lane-state", lane, "RUNNING", "--expect", "none", + "--operation", "op-seed", "--owner", UUID) + assert proc.returncode == 0, proc.stderr + + def row(self, lane: str) -> str: + return next(line for line in (self.wip / "lanes" / "LANES.md").read_text().splitlines() + if line.startswith(f"| `{lane}`")) + + def last_log(self, lane: str) -> str: + return (self.wip / "lanes" / "log" / f"{lane}.md").read_text().splitlines()[-1] + + def sweep(self, lane: str, *args) -> subprocess.CompletedProcess: + return self.tool("lane-worktrees", "sweep", lane, *args) + + +# ================================================================ normalising + +_RULES = ( + (re.compile(r"\bc\d{3,}-[A-Za-z0-9._-]*"), ""), + (re.compile(r"op-\d{8}T\d{6}Z-\d+-\d+"), ""), + (re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}"), ""), + (re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?Z"), ""), + (re.compile(r"\d{8}T\d{6}Z"), ""), + (re.compile(r"\d{4}-\d{2}-\d{2}"), ""), + (re.compile(r"\b[0-9a-f]{7,40}\b"), ""), + (re.compile(r"\b\d+ d\b"), " d"), + (re.compile(r"active \d+ min ago"), "active min ago"), + (re.compile(r"\b\d+(\.\d+)? (B|KB|MB|GB)\b"), ""), + (re.compile(r"\(\d+ bytes\)"), "( bytes)"), +) + + +_PROC_NOTE = re.compile(r"^ note: \d+ process\(es\) of this account could not be read in /proc ") + + +def normalise(text: str, bench: Bench) -> str: + """What no two runs share, and nothing else: the bench's own path and its + `$HOME` (both spellings), tree ids (a checksum of that path), operation ids, stamps, + hashes, ages and byte sizes - and a porcelain row's byte count.""" + # `$HOME` FIRST, and as a word of its own: `tests/test_repo_hygiene.py` + # rightly refuses a committed `/home//` path, and the bench's home + # sits under its root. + for place, word in ((bench.home, ""), (bench.root, "")): + for spelling in sorted({str(place), os.path.realpath(place)}, key=len, reverse=True): + text = text.replace(spelling, word) + for rx, sub in _RULES: + text = rx.sub(sub, text) + out = [] + for line in text.split("\n"): + # A HOST FACT, NOT THE TOOL'S: how many of this account's processes + # `/proc` would not show the sweep is whatever else runs beside it. + if _PROC_NOTE.match(line): + continue + cols = line.split("\t") + if cols[0] in ("scratch", "cache", "sandbox") and len(cols) > 5: + cols[5] = "" + out.append("\t".join(cols)) + return "\n".join(out) + + +def shown(proc: subprocess.CompletedProcess, bench: Bench) -> str: + return normalise(f"exit {proc.returncode}\n--- stdout\n{proc.stdout}--- stderr\n{proc.stderr}", + bench) + + +# =================================================== the single repository + +def single_repository(bench: Bench) -> list: + """THE SCENARIO WHOSE OUTPUTS MAY NOT MOVE: one repository with a + submodule (as openRepoTools has `upstream/openRepoShape`) and a second with + a `project.yaml` naming no leg but itself, every kind of row the touched + commands print, and each command's output in the order it ran.""" + b = bench + out = [] + dep = b.seeded("dep", {"d.txt": "a dependency\n"}) + b.origin("repoS") + s = b.projects / "repoS" + b.git("clone", "-q", b.remotes / "repoS.git", s) + b.commit(s, "seed", {"README.md": "S\n", ".gitignore": ".env\n__pycache__/\n"}) + b.git("submodule", "add", "-q", dep, "vendor/dep", cwd=s) + b.commit(s, "a submodule that is no leg") + b.git("push", "-q", "origin", "main", cwd=s) + b.lanes("opensoft/repoS", s, "repoS-1", "repoS-2") + + # add: the dry run, then the act; work in the tree it made. + out.append(("add --dry-run", shown(b.tool("lane-worktrees", "add", "repoS-1", "inv", + "--dry-run"), b))) + out.append(("add", shown(b.tool("lane-worktrees", "add", "repoS-1", "inv"), b))) + inv = b.projects / ".lane-worktrees" / "repoS-1" / "inv" + b.commit(inv, "inv work", {"i.txt": "i\n"}, lane="repoS-1") + (inv / "i.txt").write_text("changed\n") + (inv / ".env").write_text("SECRET=1\n") + (inv / "__pycache__").mkdir() + (inv / "__pycache__" / "x.pyc").write_bytes(b"\0" * 64) + # a tree under .claude/worktrees carrying the lane's trailer, unrecorded + tr = s / ".claude" / "worktrees" / "tr" + b.git("worktree", "add", "-q", "-b", "tr", tr, "origin/main", cwd=s) + b.commit(tr, "trailer work", {"t.txt": "t\n"}, lane="repoS-1") + # a FOREIGN registration elsewhere, another lane's + b.git("worktree", "add", "-q", "-b", "elsewhere", b.projects / "elsewhere", "origin/main", cwd=s) + b.commit(b.projects / "elsewhere", "theirs", {"e.txt": "e\n"}, lane="repoS-9") + # scratch, and a branch of the lane's own with no tree + (b.projects / ".lane-worktrees" / "repoS-1" / "notes-scratch").mkdir() + (b.projects / ".lane-worktrees" / "repoS-1" / "notes-scratch" / "n.md").write_text("n\n") + tmp = b.root / "tmp-branch" + b.git("worktree", "add", "-q", "-b", "old-work", tmp, "origin/main", cwd=s) + b.commit(tmp, "old work", {"o.txt": "o\n"}, lane="repoS-1") + b.git("worktree", "remove", tmp, cwd=s) + + out.append(("sweep porcelain", shown(b.sweep("repoS-1", *GATE, "--dry-run", "--porcelain"), b))) + out.append(("sweep table", shown(b.sweep("repoS-1", *GATE, "--dry-run"), b))) + out.append(("lane-reconcile", shown(b.tool("lanes-edit.sh", "lane-reconcile", "repoS-1"), b))) + out.append(("lane-end refused", shown(b.tool("lane-end", "repoS-1"), b))) + out.append(("lane-end refused: row and log", normalise( + b.row("repoS-1") + "\n" + b.last_log("repoS-1"), b))) + + # a repository whose project.yaml names no leg but itself + p = b.projects / "repoP" + b.origin("repoP") + b.git("clone", "-q", b.remotes / "repoP.git", p) + b.commit(p, "seed", {"project.yaml": ( + "schema_version: 1\nkind: project-manifest\nid: repoP\nlegs:\n" + " - role: assembly\n repository: opensoft/repoP\n path: \".\"\n")}) + b.git("push", "-q", "origin", "main", cwd=p) + b.lanes("opensoft/repoP", p, "repoP-1") + pt = b.projects / ".lane-worktrees" / "repoP-1" / "w" + out.append(("add (manifest, no leg)", shown(b.tool("lane-worktrees", "add", "repoP-1", "w"), b))) + b.commit(pt, "p work", {"p.txt": "p\n"}, lane="repoP-1") + out.append(("sweep porcelain (manifest, no leg)", + shown(b.sweep("repoP-1", *GATE, "--dry-run", "--porcelain"), b))) + + out.append(("lane-handoff", shown(b.tool("lane-handoff", "--lane", "repoS-1", "--transcript", + "none", "--agent", "claude", "t018 handoff", + cwd=s), b))) + handoff = (b.wip / "handoffs" / "repoS-1.md").read_text() + out.append(("lane-handoff: top block", normalise(handoff.split("\n---\n", 1)[0], b))) + # ONE SIDECAR PER TREE, LISTED IN THE ORDER OF THEIR FILE NAMES - a + # checksum of each tree's path, so of the bench's own path: sorted here. + trees = b.tool("lanes-edit.sh", "lane-trees", "repoS-1") + trees.stdout = "".join(sorted(trees.stdout.splitlines(keepends=True), + key=lambda line: normalise(line, b))) + out.append(("lane-handoff: inventory", shown(trees, b))) + out.append(("sweep porcelain after the handoff", + shown(b.sweep("repoS-1", *GATE, "--dry-run", "--porcelain"), b))) + out.append(("lane-end passes", shown(b.tool("lane-end", "repoS-2"), b))) + out.append(("lane-end passes: row and log", normalise( + b.row("repoS-2") + "\n" + b.last_log("repoS-2"), b))) + out.append(("report", shown(b.tool("lane-worktrees", "sweep", "--all", "--dry-run", "--report", + "--estate", b.projects), b))) + return out + + +def render_golden(sections: list) -> str: + return "".join(f"=== {key} ===\n{text}\n" for key, text in sections) + + +def parse_golden(text: str) -> list: + out = [] + for chunk in re.split(r"(?m)^=== (.+) ===\n", text)[1:]: + out.append(chunk) + return [(out[i], out[i + 1][:-1] if out[i + 1].endswith("\n") else out[i + 1]) + for i in range(0, len(out), 2)] + + +@pytest.mark.skipif(not sys.platform.startswith("linux"), + reason="the golden was captured on Linux, where liveness is read in /proc; " + "macOS reads it with lsof, a host fact this proof is not about") +def test_t018_single_repository_outputs_are_c45a452s_byte_for_byte(tmp_path): + """THE INVARIANT. Every output of every touched command on a single + repository - a submodule that is no leg, and a `project.yaml` naming no leg + but itself, included - equals what `c45a452`'s tools printed, stored in the + golden file this module's `--capture` wrote from them.""" + assert GOLDEN.is_file(), f"{GOLDEN} is missing; it is generated from c45a452's tools" + want = parse_golden(GOLDEN.read_text(encoding="utf-8")) + got = single_repository(Bench(tmp_path / "bench", REPO)) + assert [k for k, _ in got] == [k for k, _ in want] + for (key, have), (_k, gold) in zip(got, want): + if have != gold: + diff = "\n".join(difflib.unified_diff(gold.splitlines(), have.splitlines(), + f"c45a452: {key}", f"now: {key}", lineterm="")) + raise AssertionError(f"the single-repository output of '{key}' moved:\n{diff}") + + +# ================================================================ the assembly + +def build_assembly(b: Bench, name: str = "triad") -> dict: + """An assembly made of openRepoShape's own template bytes at the pinned + commit: every file of `templates/assembly-root/` copied, `project.yaml`'s + placeholders filled, the legs mounted as submodules from bare local + origins, and a dependency nested in the code leg (`upstream/dep`), as + openRepoTools' code leg will carry `upstream/openRepoShape`.""" + dep = b.seeded(f"{name}-dep", {"dep.txt": "a nested dependency\n"}) + spec = b.seeded(f"{name}-spec", {"spec.md": "the spec leg\n"}) + code_bare = b.origin(f"{name}-code") + seed = b.root / "seed" / f"{name}-code" + b.git("clone", "-q", code_bare, seed) + b.commit(seed, "code: seed", {"tool.sh": "#!/bin/sh\n"}) + b.git("submodule", "add", "-q", dep, "upstream/dep", cwd=seed) + b.commit(seed, "code: the nested dependency") + b.git("push", "-q", "origin", "main", cwd=seed) + b.origin(name) + a = b.projects / name + b.git("clone", "-q", b.remotes / f"{name}.git", a) + for src in TEMPLATE.rglob("*"): + rel = src.relative_to(TEMPLATE) + if src.is_dir(): + (a / rel).mkdir(parents=True, exist_ok=True) + else: + (a / rel).parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, a / rel) + manifest = (TEMPLATE / "project.yaml").read_text(encoding="utf-8") + fill = {"ASSEMBLY_REPOSITORY": f"opensoft/{name}", "SPEC_REPOSITORY": f"opensoft/{name}-spec", + "CODE_REPOSITORY": f"opensoft/{name}-code", "SPEC_PATH": "spec", "CODE_PATH": "code"} + lines = [] + for line in manifest.splitlines(): + if re.fullmatch(r"\{\{[A-Z_]+_NAMING\}\}", line.strip()): + continue + lines.append(re.sub(r"\{\{([A-Z_]+)\}\}", lambda m: fill.get(m.group(1), m.group(0)), line)) + (a / "project.yaml").write_text("\n".join(lines) + "\n", encoding="utf-8") + b.commit(a, "the assembly, from the template") + b.git("submodule", "add", "-q", spec, "spec", cwd=a) + b.git("submodule", "add", "-q", code_bare, "code", cwd=a) + b.commit(a, "the legs") + b.git("push", "-q", "origin", "main", cwd=a) + b.git("submodule", "update", "-q", "--init", "--recursive", cwd=a) + return {"assembly": a, "code": a / "code", "spec": a / "spec", + "dep": a / "code" / "upstream" / "dep"} + + +def porcelain_rows(out: str, kind: str) -> dict: + rows = {} + for line in out.splitlines(): + cols = line.split("\t") + if cols[0] == kind and len(cols) > 6: + rows[cols[3]] = {"disposition": cols[1], "retire": cols[2], "branch": cols[4], + "head": cols[5], "why": cols[6]} + return rows + + +@pytest.fixture +def triad(tmp_path): + b = Bench(tmp_path / "bench", REPO) + t = build_assembly(b) + b.lanes("opensoft/triad", t["assembly"], "triad-1", "triad-2") + return b, t + + +def _lane_start_reader() -> list: + text = (REPO / "lane-start").read_text(encoding="utf-8") + start = text.index(" ls_cand=\"$(awk -v want=\"$ls_home\" '\n") + lines = text[start:].splitlines()[1:8] + return [ln.strip() for ln in lines] + + +def _shared_reader() -> list: + text = (REPO / "lanes-edit.sh").read_text(encoding="utf-8") + start = text.index("project_leg_paths_file() {") + lines = text[start:].splitlines()[3:10] + return [ln.strip() for ln in lines] + + +def test_t018_the_legs_reader_is_lane_starts_rung_5_line_for_line(): + """NEVER A SECOND GRAMMAR: the seven lines that decide what a leg is are + the ones `lane-start` resolves a lane's directory with.""" + theirs, ours = _lane_start_reader(), _shared_reader() + assert len(theirs) == 7 and theirs[0].startswith("/^[A-Za-z_]"), theirs + assert theirs == ours + + +@NEEDS_UPSTREAM +def test_t018_project_legs_reads_the_templates_manifest(tmp_path): + b = Bench(tmp_path / "bench", REPO) + t = build_assembly(b) + a = t["assembly"] + proc = b.tool("lanes-edit.sh", "project-legs", a, env={"AGENT_PROTOCOL_ROOT": str(tmp_path / "none")}) + assert proc.returncode == 0, proc.stderr + assert proc.stdout.splitlines() == [f"opensoft/triad-spec\tspec\t{a}/spec", + f"opensoft/triad-code\tcode\t{a}/code"] + for where, rc in ((b.projects, 8), (b.projects / "wip", 8)): + proc = b.tool("lanes-edit.sh", "project-legs", where) + assert proc.returncode == rc, proc.stdout + proc.stderr + assert b.tool("lanes-edit.sh", "project-legs", "relative").returncode == 64 + os.chmod(a / "project.yaml", 0) + try: + if os.geteuid() != 0: + proc = b.tool("lanes-edit.sh", "project-legs", a) + assert proc.returncode == 1 and "could not be read" in proc.stderr + finally: + os.chmod(a / "project.yaml", 0o644) + + +@NEEDS_UPSTREAM +def test_t018_the_sweep_reads_every_leg_and_its_nested_dependency(triad): + b, t = triad + proc = b.sweep("triad-1", "--dry-run", "--porcelain") + assert proc.returncode == 0, proc.stdout + proc.stderr + legs = porcelain_rows(proc.stdout, "leg") + assert list(legs) == [str(t["spec"]), str(t["code"]), str(t["dep"])] + assert all(r["disposition"] == "clean" and r["retire"] == "-" for r in legs.values()), legs + assert "the code leg of" in legs[str(t["code"])]["why"] + assert "nested in the code leg (code/upstream/dep)" in legs[str(t["dep"])]["why"] + table = b.sweep("triad-1", "--dry-run") + assert "LEGS (the assembly's own checkouts" in table.stdout + assert "3 leg checkout(s), 0 counted" in table.stdout + + +@NEEDS_UPSTREAM +def test_t018_a_paired_code_tree_with_unpushed_lane_work_holds_lane_end(triad): + """THE GAP §4.4 MEASURED: a paired `worktrees//code` tree, made + as Speckit makes one (a worktree of the code leg's repository), holding the + lane's unpushed commit, passed `lane-end`. Now the sweep claims it by its + HEAD's own `Lane:` trailer and the gate refuses, naming it.""" + b, t = triad + tree = t["assembly"] / "worktrees" / "005-x" / "code" + b.git("worktree", "add", "-q", "-b", "005-x", tree, "origin/main", cwd=t["code"]) + b.commit(tree, "the feature's code", {"f.txt": "f\n"}, lane="triad-1") + # the paired spec tree carries another lane's work: never this lane's + spec_tree = t["assembly"] / "worktrees" / "005-x" / "spec" + b.git("worktree", "add", "-q", "-b", "005-x", spec_tree, "origin/main", cwd=t["spec"]) + b.commit(spec_tree, "the feature's spec", {"s.md": "s\n"}, lane="triad-2") + proc = b.sweep("triad-1", *GATE, "--dry-run", "--porcelain") + assert proc.returncode == 3, proc.stdout + proc.stderr + trees = porcelain_rows(proc.stdout, "tree") + assert trees[str(tree)]["retire"] == "retire", trees + assert "Lane: trailer" in trees[str(tree)]["why"] + assert trees[str(spec_tree)]["disposition"] == "foreign", trees + assert "paired feature worktrees" in trees[str(spec_tree)]["why"] + assert "the spec leg of" in trees[str(spec_tree)]["why"] + end = b.tool("lane-end", "triad-1") + assert end.returncode == 2, end.stderr + assert "lane triad-1 is NOT ended" in end.stderr and str(tree) in end.stderr + assert "1 tree(s)" in end.stderr + # and the other lane's own sweep claims the spec tree, not the code one + other = porcelain_rows(b.sweep("triad-2", "--dry-run", "--porcelain").stdout, "tree") + assert other[str(spec_tree)]["retire"] == "retire" + assert other[str(tree)]["disposition"] == "foreign" + + +@NEEDS_UPSTREAM +def test_t018_a_leg_tree_made_elsewhere_is_registered_in_its_leg(triad): + b, t = triad + away = b.projects / "away-code" + b.git("worktree", "add", "-q", "-b", "away", away, "origin/main", cwd=t["code"]) + proc = b.sweep("triad-1", "--dry-run", "--porcelain") + row = porcelain_rows(proc.stdout, "tree")[str(away)] + assert row["disposition"] == "foreign" and row["retire"] == "-", row + assert "registered in the code leg of" in row["why"] + # and a tree recorded with the leg as its checkout is the lane's + rec = b.tool("lanes-edit.sh", "set-lane-tree", "triad-1", away, "--checkout", t["code"]) + assert rec.returncode == 0, rec.stderr + row = porcelain_rows(b.sweep("triad-1", "--dry-run", "--porcelain").stdout, "tree")[str(away)] + assert row["retire"] == "retire" and row["disposition"] == "remove", row + + +@NEEDS_UPSTREAM +def test_t018_a_leg_holding_the_lanes_unpushed_commit_is_counted(triad): + """A lane working in the code leg itself: its HEAD's own trailer is this + lane's and origin lacks it, so the leg is COUNTED - the gate refuses, and + offers no `--yes`, which never acts on a leg.""" + b, t = triad + b.git("checkout", "-q", "-b", "in-place", cwd=t["code"]) + b.commit(t["code"], "worked in the leg", {"w.txt": "w\n"}, lane="triad-1") + proc = b.sweep("triad-1", "--dry-run", "--porcelain") + assert proc.returncode == 3, proc.stdout + proc.stderr + leg = porcelain_rows(proc.stdout, "leg")[str(t["code"])] + assert leg["disposition"] == "unpushed" and leg["retire"] == "retire", leg + assert leg["branch"] == "in-place" + end = b.tool("lane-end", "triad-1") + assert end.returncode == 2 + assert "1 leg checkout(s)" in end.stderr and str(t["code"]) in end.stderr + assert "no sweep acts on one" in end.stderr + assert "--yes" not in end.stderr + # another lane's sweep lists that leg and does not count it + other = b.sweep("triad-2", "--dry-run", "--porcelain") + assert other.returncode == 0, other.stdout + assert porcelain_rows(other.stdout, "leg")[str(t["code"])]["retire"] == "-" + assert b.tool("lane-end", "triad-2").returncode == 0 + + +@NEEDS_UPSTREAM +def test_t018_a_dirty_leg_that_is_no_lanes_is_listed_never_counted(triad): + b, t = triad + (t["code"] / "scratch.txt").write_text("someone's edit\n") + (t["dep"] / "d2.txt").write_text("an edit in the dependency\n") + proc = b.sweep("triad-1", "--dry-run", "--porcelain") + assert proc.returncode == 0, proc.stdout + proc.stderr + legs = porcelain_rows(proc.stdout, "leg") + assert legs[str(t["code"])]["disposition"] == "dirty" + assert legs[str(t["dep"])]["disposition"] == "dirty" + assert all(r["retire"] == "-" for r in legs.values()) + end = b.tool("lane-end", "triad-1") + assert end.returncode == 0, end.stderr + assert "close-out gate: nothing of lane triad-1's is left on disk" in end.stderr + assert "listed and NOT counted" in end.stderr and str(t["dep"]) in end.stderr + + +@NEEDS_UPSTREAM +def test_t018_the_legs_branches_are_read_under_branches(triad): + b, t = triad + tmp = b.root / "leg-branch" + b.git("worktree", "add", "-q", "-b", "leg-old", tmp, "origin/main", cwd=t["code"]) + b.commit(tmp, "leg work", {"l.txt": "l\n"}, lane="triad-1") + b.git("worktree", "remove", tmp, cwd=t["code"]) + proc = b.sweep("triad-1", "--branches", "--dry-run", "--porcelain") + assert proc.returncode == 3, proc.stdout + proc.stderr + rows = [ln.split("\t") for ln in proc.stdout.splitlines() if ln.startswith("branch\t")] + assert [r[3] for r in rows] == ["leg-old"], rows + assert rows[0][2] == "retire" and rows[0][4].startswith("in the code leg of"), rows + + +@NEEDS_UPSTREAM +def test_t018_a_manifest_or_leg_that_cannot_be_read_refuses(triad): + b, t = triad + if os.geteuid() == 0: + pytest.skip("root reads a mode-000 file") + os.chmod(t["assembly"] / "project.yaml", 0) + try: + proc = b.sweep("triad-1", "--dry-run", "--porcelain") + finally: + os.chmod(t["assembly"] / "project.yaml", 0o644) + assert proc.returncode == 2, proc.stdout + assert "the legs of the assembly" in proc.stdout and "could not be read" in proc.stdout + + +@NEEDS_UPSTREAM +def test_t018_a_leg_git_cannot_read_is_counted_and_holds_lane_end(triad): + """A LEG WITH A `.git` OF ITS OWN THAT GIT CANNOT READ is a read that + failed, never an absent or a clean leg (Amendment 7(d)): its row is + `unreadable` and counted, the dry run exits 3, and `lane-end` refuses, + naming it.""" + b, t = triad + (t["spec"] / ".git").write_text("gitdir: /nonexistent/t018-spec\n") + proc = b.sweep("triad-1", "--dry-run", "--porcelain") + assert proc.returncode == 3, proc.stdout + proc.stderr + leg = porcelain_rows(proc.stdout, "leg")[str(t["spec"])] + assert leg["disposition"] == "unreadable" and leg["retire"] == "retire", leg + assert "git could not be read through it" in leg["why"], leg + end = b.tool("lane-end", "triad-1") + assert end.returncode == 2, end.stderr + assert "lane triad-1 is NOT ended" in end.stderr and str(t["spec"]) in end.stderr + + +@NEEDS_UPSTREAM +def test_t018_dependencies_a_leg_nests_that_cannot_be_listed_refuse_the_sweep(triad): + """THE SWEEP'S OWN REFUSAL, UNSHADOWED: `git submodule status --recursive` + failing in a checked-out leg - here a nested dependency whose `.git` names + no repository - leaves the assembly's checkouts unknown, so the sweep + refuses, exit 2. `lane-reconcile` does not read nested dependencies, so its + verdict is no `indeterminate` that refuses first: this is `_assembly`'s.""" + b, t = triad + a = t["assembly"] + (t["dep"] / ".git").write_text("gitdir: /nonexistent/t018-dep\n") + rec = b.tool("lanes-edit.sh", "lane-reconcile", "triad-1") + verdict = next((ln.split(US)[1] for ln in rec.stdout.splitlines() + if ln.split(US)[0] == "VERDICT"), "") + assert verdict and verdict != "indeterminate", rec.stdout + proc = b.sweep("triad-1", "--dry-run", "--porcelain") + assert proc.returncode == 2, proc.stdout + proc.stderr + assert f"the legs of the assembly {a} could not be read" in proc.stdout, proc.stdout + assert "the dependencies nested in the code leg" in proc.stdout + assert "could not be listed (git submodule status failed)" in proc.stdout + + +@NEEDS_UPSTREAM +def test_t018_a_leg_that_is_not_checked_out_is_absent_and_claims_no_tree_of_the_assembly(triad): + """A DEINITIALIZED LEG is an empty directory of the assembly's, where git + answers the ASSEMBLY. The sweep lists it `absent`, counted for nobody; the + assembly's own trees and registrations stay the assembly's rather than + becoming that leg's (`probe_leg`'s toplevel guard); and `add` from the + assembly records the assembly, never the empty leg (`add`'s guard).""" + b, t = triad + a = t["assembly"] + b.git("submodule", "deinit", "-q", "spec", cwd=a) + assert not any((a / "spec").iterdir()) + made = b.tool("lane-worktrees", "add", "triad-1", "root-work") + assert made.returncode == 0, made.stderr + tree = made.stdout.strip() + assert "is an ASSEMBLY, and this tree is of the assembly itself" in made.stderr + assert "the spec leg of" not in made.stderr, made.stderr + assert _inventory(b, "triad-1")[tree] == str(a) + away = b.projects / "away-root" + b.git("worktree", "add", "-q", "-b", "away-root", away, "origin/main", cwd=a) + proc = b.sweep("triad-1", "--dry-run", "--porcelain") + legs = porcelain_rows(proc.stdout, "leg") + assert legs[str(t["spec"])]["disposition"] == "absent", legs + assert legs[str(t["spec"])]["retire"] == "-", legs + assert "not checked out here" in legs[str(t["spec"])]["why"], legs + assert legs[str(t["code"])]["disposition"] == "clean", legs + trees = porcelain_rows(proc.stdout, "tree") + assert trees[tree]["retire"] == "retire", trees + for path in (tree, str(away)): + assert "spec leg" not in trees[path]["why"], trees[path] + assert trees[str(away)]["disposition"] == "foreign", trees + + +def _lane_worktrees_module(): + """`lane-worktrees` as a module, its bytecode written nowhere (as + tests/test_lane_worktrees.py loads it).""" + import importlib.util + from importlib.machinery import SourceFileLoader + loader = SourceFileLoader("lane_worktrees_t018", str(REPO / "lane-worktrees")) + spec = importlib.util.spec_from_loader("lane_worktrees_t018", loader) + module = importlib.util.module_from_spec(spec) + was = sys.dont_write_bytecode + sys.dont_write_bytecode = True + try: + loader.exec_module(module) + finally: + sys.dont_write_bytecode = was + return module + + +def test_t018_the_sweeps_paired_root_refuses_what_it_cannot_list_or_search(tmp_path): + """THE SWEEP'S OWN PAIRED-ROOT REFUSAL (`Sweep._paired`), which no run + shows: `lane-reconcile` reads the same directories first and says + `indeterminate`, and the sweep refuses on that verdict before it looks. + Called directly: the root, a feature directory, or a leg's directory under + it that cannot be listed or searched each refuse, naming it; readable, + the tree in it is found.""" + if os.geteuid() == 0: + pytest.skip("root lists and searches a mode-000 directory") + import types + lw = _lane_worktrees_module() + root = tmp_path / "worktrees" + feature = root / "001-a" + tree = feature / "code" + tree.mkdir(parents=True) + (tree / ".git").write_text("gitdir: /nonexistent\n") + legs = [types.SimpleNamespace(decl="spec", nested_in=""), + types.SimpleNamespace(decl="code", nested_in="")] + + def paired(): + sweep = types.SimpleNamespace(paired_root=str(root), legs=legs, refusal="") + seen: dict = {} + lw.Sweep._paired(sweep, seen) + return sweep.refusal, seen + + refusal, seen = paired() + assert refusal == "" and list(seen) == [lw.real(str(tree))], (refusal, seen) + for target, mode, words in ((root, 0, "worktree root"), (root, 0o600, "worktree root"), + (feature, 0, "feature directory"), + (feature, 0o600, "feature directory"), + (tree, 0o600, "paired tree directory")): + os.chmod(target, mode) + try: + refusal, seen = paired() + finally: + os.chmod(target, 0o755) + assert f"{words} {target} could not be" in refusal, (target, oct(mode), refusal) + assert not seen, (target, oct(mode), seen) + + +@NEEDS_UPSTREAM +def test_t018_project_legs_reads_quoted_scalars_as_openreposhape_v1_writes_them(triad): + """openRepoShape v1.0.0 (opensoft/openRepoShape#395) writes the assembly + template's scalars QUOTED: `path: "{{SPEC_PATH}}"` under `legs:`, and `id:`, + `elected_on:`, `visibility:` and `tracking_branch:` alike. The one reader + every lane tool asks reads `path: "code"` and `path: 'code'` - and a quoted + `repository:` - exactly as `path: code`, so the sweep's leg rows are the + same three.""" + b, t = triad + a = t["assembly"] + want = [f"opensoft/triad-spec\tspec\t{a}/spec", f"opensoft/triad-code\tcode\t{a}/code"] + proc = b.tool("lanes-edit.sh", "project-legs", a) + assert proc.returncode == 0 and proc.stdout.splitlines() == want, proc.stdout + proc.stderr + plain = (a / "project.yaml").read_text(encoding="utf-8") + for q in ('"', "'"): + lines, inlegs, quoted = [], False, 0 + for line in plain.splitlines(): + if re.match(r"^[A-Za-z_][A-Za-z0-9_]*:", line): + inlegs = line.startswith("legs:") + top = re.match(r"^(id|elected_on|visibility|tracking_branch):\s*(\S.*?)\s*$", line) + leg = re.match(r"^(\s+(?:repository|path):\s*)(\S.*?)\s*$", line) if inlegs else None + m = top or leg + if m: + head = f"{m.group(1)}: " if top else m.group(1) + line = f"{head}{q}{m.group(2).strip(chr(34) + chr(39))}{q}" + quoted += 1 + lines.append(line) + assert quoted >= 10, quoted + (a / "project.yaml").write_text("\n".join(lines) + "\n", encoding="utf-8") + assert f"path: {q}spec{q}" in (a / "project.yaml").read_text() + proc = b.tool("lanes-edit.sh", "project-legs", a) + assert proc.returncode == 0, proc.stderr + assert proc.stdout.splitlines() == want, (q, proc.stdout) + legs = porcelain_rows(b.sweep("triad-1", "--dry-run", "--porcelain").stdout, "leg") + assert list(legs) == [str(t["spec"]), str(t["code"]), str(t["dep"])], (q, legs) + + +# ========================================================= (c) the daily report + +def _section(text: str, title: str) -> str: + start = text.index(f"## {title}") + end = text.find("\n## ", start + 3) + return text[start:end if end != -1 else len(text)] + + +@NEEDS_UPSTREAM +def test_t018_the_report_counts_each_leg_as_a_checkout_of_its_own(triad): + b, t = triad + a = t["assembly"] + tmp = b.root / "leg-branch" + b.git("worktree", "add", "-q", "-b", "leg-unpushed", tmp, "origin/main", cwd=t["code"]) + b.commit(tmp, "leg work", {"l.txt": "l\n"}, lane="triad-1") + b.git("worktree", "remove", tmp, cwd=t["code"]) + (t["spec"] / "edit.md").write_text("an edit in the spec leg\n") + proc = b.tool("lane-worktrees", "sweep", "--all", "--dry-run", "--report", "--estate", + b.projects) + assert proc.returncode == 0, proc.stderr + branches = _section(proc.stdout, "Unmerged branches with a missing, diverged or unpushed upstream") + assert f"- {t['code']} leg-unpushed ·" in branches, branches + assert f"the code leg of {a}" in branches + legs = _section(proc.stdout, "Assembly legs with uncommitted or unpublished work") + assert f"- {t['spec']} · the spec leg of {a} ·" in legs, legs + assert "1 dirty or untracked path(s)" in legs + assert str(t["code"]) + " ·" not in legs + assert "| Assembly legs with uncommitted or unpublished work | 1 |" in proc.stdout + + +def test_t018_a_report_with_no_assembly_has_no_legs_section(tmp_path): + b = Bench(tmp_path / "bench", REPO) + b.seeded("plain", {"p.txt": "p\n"}) + b.git("clone", "-q", b.remotes / "plain.git", b.projects / "plain") + proc = b.tool("lane-worktrees", "sweep", "--all", "--dry-run", "--report", "--estate", + b.projects) + assert proc.returncode == 0, proc.stderr + assert "Assembly legs" not in proc.stdout + + +def test_t018_the_report_lists_a_leg_that_is_also_a_canonical_clone_once(tmp_path): + """FINDING 1 OF THE READ-ONLY TRIAD MEASUREMENT (2026-10-17): a leg whose + `.git` is a DIRECTORY - a clone, not a submodule's gitfile - is also one of + the estate's canonical checkouts, and the report read it once as each, so + its branch rows printed twice (8 rows on that workstation) and its `main` + row would have. Each leg's rows now appear once, still naming the leg, in + the branch, `main` and rescue sections. The manifest is written here, so + no template is needed.""" + b = Bench(tmp_path / "bench", REPO) + a = b.projects / "cl" + b.origin("cl") + b.git("clone", "-q", b.remotes / "cl.git", a) + b.commit(a, "the assembly, its legs clones", { + ".gitignore": "spec/\ncode/\n", + "project.yaml": ("schema_version: 1\nkind: project-manifest\nid: \"cl\"\nlegs:\n" + " - role: assembly\n repository: opensoft/cl\n path: \".\"\n" + " - role: spec\n repository: opensoft/cl-spec\n path: \"spec\"\n" + " - role: code\n repository: opensoft/cl-code\n path: \"code\"\n")}) + b.git("push", "-q", "origin", "main", cwd=a) + spec, code = a / "spec", a / "code" + b.git("clone", "-q", b.seeded("cl-spec", {"s.md": "s\n"}), spec) + b.git("clone", "-q", b.seeded("cl-code", {"c.txt": "c\n"}), code) + assert (spec / ".git").is_dir() and (code / ".git").is_dir() + # the code leg: a branch with no upstream, and a rescue branch past the aging line + b.git("checkout", "-q", "-b", "leg-unpushed", cwd=code) + b.commit(code, "leg work", {"l.txt": "l\n"}, lane="cl-1") + b.git("checkout", "-q", "-b", "rescue/cl-1/old", "main", cwd=code) + old = {"GIT_AUTHOR_DATE": "2020-01-01T00:00:00+00:00", + "GIT_COMMITTER_DATE": "2020-01-01T00:00:00+00:00"} + made = b.run("git", "commit", "-q", "--allow-empty", "-m", "old rescue", cwd=code, env=old) + assert made.returncode == 0, made.stderr + b.git("checkout", "-q", "main", cwd=code) + # the spec leg: local main ahead of origin + b.commit(spec, "spec work on main", {"m.md": "m\n"}, lane="cl-1") + + proc = b.tool("lane-worktrees", "sweep", "--all", "--dry-run", "--report", "--estate", + b.projects) + assert proc.returncode == 0, proc.stderr + foreign = _section(proc.stdout, "FOREIGN repositories") + assert str(spec) not in foreign and str(code) not in foreign, foreign # both canonical + branches = _section(proc.stdout, "Unmerged branches with a missing, diverged or unpushed upstream") + rows = [ln for ln in branches.splitlines() if ln.startswith(f"- {code} leg-unpushed ·")] + assert len(rows) == 1, branches + assert rows[0].endswith(f"the code leg of {a}"), rows + root_main = _section(proc.stdout, "Root main divergence") + rows = [ln for ln in root_main.splitlines() if ln.startswith(f"- {spec} · local main ahead")] + assert len(rows) == 1, root_main + assert rows[0].endswith(f"the spec leg of {a}"), rows + aging = _section(proc.stdout, "Awaiting disposition") + rows = [ln for ln in aging.splitlines() if ln.startswith(f"- {code} rescue/cl-1/old ·")] + assert len(rows) == 1, aging + assert rows[0].endswith(f"the code leg of {a}"), rows + legs = _section(proc.stdout, "Assembly legs with uncommitted or unpublished work") + assert legs.count(f"- {spec} · the spec leg of {a} ·") == 1, legs + + +# ================================================================ (d) add + +def _inventory(b: Bench, lane: str) -> dict: + """{path: checkout} of the lane's #97 inventory.""" + proc = b.tool("lanes-edit.sh", "lane-trees", lane) + assert proc.returncode in (0, 8), proc.stderr + rows = {} + for line in proc.stdout.splitlines(): + cols = line.split(US) + if len(cols) > 9: + rows[cols[1]] = cols[9] + return rows + + +@NEEDS_UPSTREAM +def test_t018_add_records_the_leg_it_made_a_tree_from(triad): + b, t = triad + a = t["assembly"] + dry = b.tool("lane-worktrees", "add", "triad-1", "c1", "--checkout", t["code"], "--dry-run") + assert dry.returncode == 0, dry.stderr + assert f"--checkout {t['code']} - the code leg of {a}/project.yaml (opensoft/triad-code)" \ + in dry.stderr + made = b.tool("lane-worktrees", "add", "triad-1", "c1", "--checkout", t["code"]) + assert made.returncode == 0, made.stderr + tree = Path(made.stdout.strip()) + assert f"in {t['code']} - the code leg of {a}/project.yaml" in made.stderr + assert _inventory(b, "triad-1")[str(tree)] == str(t["code"]) + # FROM A WORKTREE OF THE LEG'S REPOSITORY, the leg is what is recorded + paired = a / "worktrees" / "010-v" / "code" + b.git("worktree", "add", "-q", "-b", "010-v", paired, "origin/main", cwd=t["code"]) + made = b.tool("lane-worktrees", "add", "triad-1", "c2", "--checkout", paired) + assert made.returncode == 0, made.stderr + assert f"with {t['code']} as its checkout" in made.stderr + assert _inventory(b, "triad-1")[made.stdout.strip()] == str(t["code"]) + # the sweep then reads it through its leg + row = porcelain_rows(b.sweep("triad-1", "--dry-run", "--porcelain").stdout, + "tree")[made.stdout.strip()] + assert row["retire"] == "retire", row + + +@NEEDS_UPSTREAM +def test_t018_add_from_the_assembly_itself_says_its_legs_are_empty(triad): + b, t = triad + a = t["assembly"] + made = b.tool("lane-worktrees", "add", "triad-1", "root-work") + assert made.returncode == 0, made.stderr + assert "is an ASSEMBLY, and this tree is of the assembly itself" in made.stderr + # every leg is named, never only the last one the manifest declares + assert f"--checkout {a}/spec or --checkout {a}/code" in made.stderr, made.stderr + assert _inventory(b, "triad-1")[made.stdout.strip()] == str(a) + + + +@NEEDS_UPSTREAM +def test_t018_add_refuses_where_the_manifest_cannot_be_read(triad): + """Never "no legs" (Amendment 7(d)): which checkout the tree would be + recorded under is unknown, so nothing is made, in the words the sweep + and the reconciliation use for the same fact.""" + b, t = triad + if os.geteuid() == 0: + pytest.skip("root reads a mode-000 file") + os.chmod(t["assembly"] / "project.yaml", 0) + try: + proc = b.tool("lane-worktrees", "add", "triad-1", "c9", "--checkout", t["code"]) + finally: + os.chmod(t["assembly"] / "project.yaml", 0o644) + assert proc.returncode == 2, proc.stderr + assert (f"REFUSED, nothing was made: the legs of the assembly {t['assembly']} could not " + "be read") in proc.stderr + assert not (t["assembly"].parent / ".lane-worktrees" / "triad-1" / "c9").exists() + assert not any(p.endswith("/c9") for p in _inventory(b, "triad-1")) + +# ===================================================================== capture + +def capture(tools: Path, golden: Path) -> None: + """Write the golden file from the tools in `tools` - run ONCE, against + `c45a452`'s (`git archive c45a452 | tar -x -C `), and committed.""" + with tempfile.TemporaryDirectory(prefix="t018-capture.") as tmp: + text = render_golden(single_repository(Bench(Path(tmp) / "bench", tools))) + golden.parent.mkdir(parents=True, exist_ok=True) + golden.write_text(text, encoding="utf-8") + + +if __name__ == "__main__": + if len(sys.argv) in (3, 4) and sys.argv[1] == "--capture": + capture(Path(sys.argv[2]).resolve(), Path(sys.argv[3]) if len(sys.argv) == 4 else GOLDEN) + sys.exit(0) + sys.stderr.write("usage: test_triad_lane_tooling.py --capture []\n") + sys.exit(64) diff --git a/tests/test_triad_lane_tooling_handoff.py b/tests/test_triad_lane_tooling_handoff.py new file mode 100644 index 00000000..0dbdfad0 --- /dev/null +++ b/tests/test_triad_lane_tooling_handoff.py @@ -0,0 +1,892 @@ +# SPDX-License-Identifier: Apache-2.0 +"""T018 (b) (opensoft/openRepoTools#186): `lane-handoff` and `lane-reconcile` +see a TRIAD's trees. + +A lane's checkout is an ASSEMBLY when its own `project.yaml` declares a leg +other than itself - read by `lanes-edit.sh project-legs`, the one reader every +lane tool asks. Its legs are submodules, and a feature's trees are worktrees of +the LEGS' repositories, so before this (docs/triad-lane-tooling-analysis.md +§4.4 and §4.7): + + 1. `lane-handoff`'s writer poll never looked in the paired feature trees + `/worktrees//{spec,code}`, so a lane with unpushed work + there was absent from its WRITERS block; + 2. a handoff re-recorded a tree made by `lane-worktrees add --checkout + /code` with the ASSEMBLY as its checkout, so the leg `add` had + recorded was lost from the inventory at the first handoff; + 3. `lane-reconcile` recomputed from the checkout's own worktree registrations + and never read the legs', nor the paired root. + +Each is held here on an assembly built from openRepoShape's own template bytes +at the pinned commit, with every leg a real git repository behind a bare local +origin and a fake `gh` that exits 1 (the bench of `tests/test_triad_lane_tooling.py`, +imported, so the two modules build one assembly one way). AND THE CONDITION OF +ALL THREE: on a single repository - no `project.yaml`, or one naming no leg but +itself - the handoff's output, its top block, the inventory it writes and the +reconciliation are byte for byte what `c45a452`'s tools printed, even where the +repository is laid out to look like a triad (a submodule with a worktree under +`worktrees//code`, and a tree recorded with the submodule as its +checkout). That "before" is STORED (`tests/fixtures/t018-handoff/`), generated +ONCE from `c45a452`'s own tools by this module's `--capture`, because CI checks +out at depth 1; `tests/test_triad_lane_tooling.py`'s own golden covers the +ordinary single repository and keeps passing unregenerated. + +NOTHING REAL IS TOUCHED: every repository, origin, register, `$HOME` and state +directory is under `tmp_path`, and the network is never reached. +""" + +from __future__ import annotations + +import difflib +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from conftest import NEEDS_UPSTREAM, REPO, WINDOWS_SKIP # noqa: E402 +from test_triad_lane_tooling import ( # noqa: E402 + US, Bench, build_assembly, normalise, parse_golden, porcelain_rows, render_golden, shown) + +pytestmark = [WINDOWS_SKIP, pytest.mark.skipif( + shutil.which("git") is None or shutil.which("bash") is None, + reason="the lane tools are bash and Python over git")] + +GOLDEN = (Path(__file__).resolve().parent / "fixtures" / "t018-handoff" + / "single-repository-decoys.golden") + + +# ================================================================== helpers + +def inventory(b: Bench, lane: str) -> dict: + """{path: checkout} of the lane's #97 inventory, `lane-trees`' tenth field.""" + proc = b.tool("lanes-edit.sh", "lane-trees", lane) + assert proc.returncode in (0, 8), proc.stderr + rows = {} + for line in proc.stdout.splitlines(): + cols = line.split(US) + if len(cols) > 9: + rows[cols[1]] = cols[9] + return rows + + +def reconcile_rows(out: str) -> dict: + """{physical path: (class, why)} of `lane-reconcile`'s TREE rows. Keyed by + the PHYSICAL path, because git's registrations are spelled physically and + a recorded tree as it was polled (macOS keeps `$TMPDIR` under `/var`, a + symlink); look a path up with `real()`.""" + rows = {} + for line in out.splitlines(): + cols = line.split(US) + if cols[0] == "TREE" and len(cols) > 4: + rows[real(cols[3])] = (cols[2], cols[4]) + return rows + + +def real(path) -> str: + return os.path.realpath(str(path)) + + +def field(out: str, key: str) -> list: + return next((ln.split(US)[1:] for ln in out.splitlines() if ln.split(US)[0] == key), []) + + +def handoff(b: Bench, lane: str, cwd: Path, why: str = "t018 handoff", + *flags: str) -> subprocess.CompletedProcess: + return b.tool("lane-handoff", "--lane", lane, "--transcript", "none", "--agent", "claude", + *flags, why, cwd=cwd) + + +def writers_file(b: Bench, lane: str, rows: dict) -> Path: + """A `--writers-file`: ` TAB `, one a line.""" + path = b.root / f"writers-{lane}.tsv" + path.write_text("".join(f"{tree}\t{brief}\n" for tree, brief in rows.items())) + return path + + +def top_block(b: Bench, lane: str) -> str: + return (b.wip / "handoffs" / f"{lane}.md").read_text().split("\n---\n", 1)[0] + + +def git_says(b: Bench, leg: Path) -> str: + """WHAT EVERY READER QUOTES for a leg git cannot read - the sweep's + `probe_leg`, `lane-reconcile` and `lane-handoff` alike: git's own answer to + `rev-parse --show-toplevel` in the leg, its first non-empty error line, or + `git answers there` where git names another checkout. Asked of + the git under test, never spelled here, because git words the same + condition differently by version: for a `.git` file naming a directory that + does not exist, 2.43 says `fatal: not a git repository: ` and + 2.55 (CI's ubuntu runner, PR #246) says `fatal: not a git repository: + (null)`. The tools read the CONDITION (git's toplevel there is not the leg, + and a `.git` stands in it); only this quoted clause is git's.""" + proc = b.run("git", "-C", leg, "rev-parse", "--show-toplevel") + if proc.returncode == 0: + return f"git answers {proc.stdout.strip()} there" + return next((ln.strip() for ln in proc.stderr.splitlines() if ln.strip()), "no answer") + + +def leg_tree(b: Bench, leg: Path, path: Path, branch: str) -> Path: + """A worktree of `leg`'s repository at `path`, on a new branch from origin/main.""" + b.git("worktree", "add", "-q", "-b", branch, path, "origin/main", cwd=leg) + return path + + +@pytest.fixture +def triad(tmp_path): + b = Bench(tmp_path / "bench", REPO) + t = build_assembly(b) + b.lanes("opensoft/triad", t["assembly"], "triad-1", "triad-2") + return b, t + + +# ============================================================= (1) and (2) + +@NEEDS_UPSTREAM +def test_t018_the_handoff_polls_the_paired_trees_and_records_each_tree_under_its_leg(triad): + """(1) THE PAIRED TREES ARE POLLED: a paired code tree holding the lane's + unpushed commit and a paired spec tree holding a writer's first edit are in + the WRITERS block, and what under the paired root is not a checkout of its + own is not. (2) EACH TREE IS RECORDED UNDER ITS LEG: the code tree `add` + made with `--checkout /code` keeps the leg as its checkout through + the handoff, the paired trees are recorded under theirs, and a tree of the + assembly itself is still recorded under the assembly. The reconciliation + then reads every one of them as inventoried, never as unmanaged.""" + b, t = triad + a, code, spec = t["assembly"], t["code"], t["spec"] + made = b.tool("lane-worktrees", "add", "triad-1", "c1", "--checkout", code) + assert made.returncode == 0, made.stderr + lane_code = Path(made.stdout.strip()) + assert inventory(b, "triad-1")[str(lane_code)] == str(code) + b.commit(lane_code, "the slice's code", {"c1.txt": "c1\n"}, lane="triad-1") + made = b.tool("lane-worktrees", "add", "triad-1", "root") + assert made.returncode == 0, made.stderr + lane_root = Path(made.stdout.strip()) + paired_code = leg_tree(b, code, a / "worktrees" / "007-y" / "code", "007-y") + b.commit(paired_code, "the feature's code", {"f.txt": "f\n"}, lane="triad-1") + paired_spec = leg_tree(b, spec, a / "worktrees" / "007-y" / "spec", "007-y") + (paired_spec / "draft.md").write_text("a writer's first edit\n") + # under the paired root and NOT a checkout of its own: the assembly's tree + (a / "worktrees" / "007-y" / "notes").mkdir() + (a / "worktrees" / "007-y" / "notes" / "n.md").write_text("n\n") + # the spec writer has committed nothing, so no trailer finds it: the + # writers file names it, and that makes it this lane's (M1 (a)) + wf = writers_file(b, "triad-1", {paired_spec: "the 007-y spec writer"}) + + proc = handoff(b, "triad-1", a, "t018 handoff", "--writers-file", str(wf)) + assert proc.returncode == 0, proc.stdout + proc.stderr + for tree, branch in ((lane_code, "c1"), (lane_root, "root"), (paired_code, "007-y"), + (paired_spec, "007-y")): + assert f"WRITER {tree} — branch {branch}" in proc.stdout, proc.stdout + for not_one in (a / "worktrees" / "007-y" / "notes", a / "worktrees" / "007-y"): + assert f"WRITER {not_one} —" not in proc.stdout, proc.stdout + assert f"writers polled: 4 under {a}" in proc.stderr, proc.stderr + assert "lifecycle: SWAPPED" in proc.stdout, proc.stdout + assert "NOT POLLED" not in proc.stdout + proc.stderr + + block = top_block(b, "triad-1") + assert f"(4 found under `{a}`)" in block, block + assert (f"- `{paired_code}` — branch `007-y`, last commit" in block + and "0 dirty, 1 unpushed" in block.split(f"`{paired_code}`", 1)[1].split("\n", 1)[0]), block + assert "1 dirty, 0 unpushed" in block.split(f"`{paired_spec}`", 1)[1].split("\n", 1)[0], block + assert "brief: the 007-y spec writer" in block, block + assert "PAIRED ROOT" not in block and "PAIRED ROOT" not in proc.stdout, block + + inv = inventory(b, "triad-1") + assert inv == {str(lane_code): str(code), str(lane_root): str(a), + str(paired_code): str(code), str(paired_spec): str(spec)}, inv + + rec = b.tool("lanes-edit.sh", "lane-reconcile", "triad-1") + assert rec.returncode == 0, rec.stderr + rows = reconcile_rows(rec.stdout) + assert rows[real(paired_code)][0] == "unpushed", rows + assert rows[real(paired_spec)][0] == "dirty", rows + assert not [p for p, (cls, _w) in rows.items() if cls in ("unmanaged", "stale-registration")], rows + assert field(rec.stdout, "TREES")[0] == "4 inventoried", rec.stdout + + +@NEEDS_UPSTREAM +def test_t018_a_leg_that_is_not_checked_out_claims_no_tree_of_the_assembly(triad): + """A LEG THAT IS NOT CHECKED OUT is an empty directory of the assembly's, + where git answers the ASSEMBLY - so read naively, its repository would be + the assembly's and every tree of the assembly would be recorded under it. + Deinitialized, the spec leg names no tree; the code leg still names its own.""" + b, t = triad + a, code = t["assembly"], t["code"] + b.git("submodule", "deinit", "-q", "spec", cwd=a) + assert not any((a / "spec").iterdir()) + made = b.tool("lane-worktrees", "add", "triad-1", "root") + assert made.returncode == 0, made.stderr + lane_root = Path(made.stdout.strip()) + paired_code = leg_tree(b, code, a / "worktrees" / "008-z" / "code", "008-z") + b.commit(paired_code, "the feature's code", {"z.txt": "z\n"}, lane="triad-1") + proc = handoff(b, "triad-1", a) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert f"writers polled: 2 under {a}" in proc.stderr, proc.stderr + # not checked out is no unreadable leg: it has no `.git` of its own + assert f"the leg {a / 'spec'} is unreadable" not in proc.stderr, proc.stderr + assert "UNREADABLE LEG" not in top_block(b, "triad-1") + assert inventory(b, "triad-1") == {str(lane_root): str(a), str(paired_code): str(code)} + + +@NEEDS_UPSTREAM +def test_t018_a_manifest_the_handoff_cannot_read_is_said_and_the_handoff_goes_on(triad): + """A `project.yaml` that is there and cannot be read is never "no legs" + (Amendment 7(d)), and a swap is never left unwritten (`R-A11-11`): the + handoff exits 0, the paired root is NOT polled and the top block says so, + a tree of the assembly's own repository is recorded as always, a tree of a + leg keeps the checkout `add` recorded rather than one nobody could + establish, and the lane stays SWAPPING.""" + b, t = triad + if os.geteuid() == 0: + pytest.skip("root reads a mode-000 file") + a, code = t["assembly"], t["code"] + made = b.tool("lane-worktrees", "add", "triad-1", "c1", "--checkout", code) + assert made.returncode == 0, made.stderr + lane_code = Path(made.stdout.strip()) + made = b.tool("lane-worktrees", "add", "triad-1", "root") + assert made.returncode == 0, made.stderr + lane_root = Path(made.stdout.strip()) + paired_code = leg_tree(b, code, a / "worktrees" / "009-w" / "code", "009-w") + b.commit(paired_code, "the feature's code", {"w.txt": "w\n"}, lane="triad-1") + os.chmod(a / "project.yaml", 0) + try: + proc = handoff(b, "triad-1", a, "unreadable manifest") + finally: + os.chmod(a / "project.yaml", 0o644) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert (f"the legs of the assembly {a} could not be read (its project.yaml is there, " + "and ") in proc.stderr, proc.stderr + assert "paired feature trees" in proc.stderr and "were NOT polled" in proc.stderr + assert f"WRITER {paired_code} —" not in proc.stdout, proc.stdout + assert f"WRITER {lane_code} —" in proc.stdout and f"WRITER {lane_root} —" in proc.stdout + assert f"the worktree inventory entry for {lane_code} was NOT written" in proc.stderr + assert "lifecycle: SWAPPING" in proc.stdout, proc.stdout + assert "the assembly's legs could not be read" in proc.stderr + block = top_block(b, "triad-1") + assert "- NOT POLLED: " in block and f"`{a}/worktrees`" in block, block + assert inventory(b, "triad-1") == {str(lane_code): str(code), str(lane_root): str(a)} + + +@NEEDS_UPSTREAM +def test_t018_a_handoff_takes_no_other_lanes_paired_tree(triad): + """M1 OF THE PRE-PR REVIEW, OPTION (a): THE PAIRED ROOT IS POLLED BY THE + SWEEP'S RULE. Two lanes share one assembly, and its paired root holds lane + 2's trees: one whose HEAD's own trailer is lane 2's, unpushed, and one with + nothing committed that only lane 2's writers file names. Lane 1's handoff + neither polls, counts nor records either - each is one `PAIRED ROOT, not + this lane's` line outside WRITERS - so lane 1's `lane-end` passes; and lane + 2's handoff polls and records both, each under its leg.""" + b, t = triad + a, code, spec = t["assembly"], t["code"], t["spec"] + theirs = leg_tree(b, code, a / "worktrees" / "020-b" / "code", "020-b") + b.commit(theirs, "lane 2's feature code", {"b.txt": "b\n"}, lane="triad-2") + fresh = leg_tree(b, spec, a / "worktrees" / "021-c" / "spec", "021-c") + (fresh / "draft.md").write_text("lane 2's writer's first edit\n") + wf2 = writers_file(b, "triad-2", {fresh: "lane 2's 021-c spec writer"}) + + one = handoff(b, "triad-1", a) + assert one.returncode == 0, one.stdout + one.stderr + for tree in (theirs, fresh): + assert f"WRITER {tree} —" not in one.stdout, one.stdout + assert f"PAIRED ROOT, not this lane's: {tree} — branch" in one.stdout, one.stdout + assert f"writers polled: 0 under {a}" in one.stderr, one.stderr + assert "paired root: 2 tree(s)" in one.stderr, one.stderr + assert "lifecycle: SWAPPED" in one.stdout, one.stdout + assert "NOT POLLED" not in one.stdout + one.stderr + block = top_block(b, "triad-1") + writers, _sep, rest = block.partition("**PAIRED ROOT at ") + assert "(0 found under" in writers and "- none running at the handoff." in writers, block + assert str(theirs) not in writers and str(fresh) not in writers, block + assert f"2 tree(s) under `{a}/worktrees` that are NOT this lane's" in rest, block + assert (f"- PAIRED ROOT, not this lane's: `{theirs}` — branch `020-b`, last commit " + in rest), block + assert "HEAD's own `Lane:` trailer `triad-2`" in rest, block + assert f"- PAIRED ROOT, not this lane's: `{fresh}` — branch `021-c`" in rest, block + assert "HEAD's own `Lane:` trailer none" in rest, block + assert inventory(b, "triad-1") == {} + end = b.tool("lane-end", "triad-1") + assert end.returncode == 0, end.stdout + end.stderr + + two = handoff(b, "triad-2", a, "t018 handoff", "--writers-file", str(wf2)) + assert two.returncode == 0, two.stdout + two.stderr + for tree in (theirs, fresh): + assert f"WRITER {tree} —" in two.stdout, two.stdout + assert f"writers polled: 2 under {a}" in two.stderr, two.stderr + assert "PAIRED ROOT" not in two.stdout and "PAIRED ROOT" not in top_block(b, "triad-2") + assert inventory(b, "triad-2") == {str(theirs): str(code), str(fresh): str(spec)} + + +@NEEDS_UPSTREAM +def test_t018_a_paired_directory_the_handoff_cannot_list_is_said_and_the_lane_stays_swapping(triad): + """S1 OF THE PRE-PR REVIEW: a paired root, a feature directory, or a leg's + directory under one that could not be listed or searched hid what stood in + it from the poll IN SILENCE, while `lane-reconcile` said `indeterminate` + and the sweep refused. Now each is a `- NOT POLLED` line in WRITERS and a + note on stderr, the handoff still exits 0 (`R-A11-11`), and the lane stays + SWAPPING; the reconciliation is `indeterminate` for every one of them.""" + b, t = triad + if os.geteuid() == 0: + pytest.skip("root lists and searches a mode-000 directory") + a, code = t["assembly"], t["code"] + mine = leg_tree(b, code, a / "worktrees" / "030-m" / "code", "030-m") + b.commit(mine, "the lane's feature code", {"m.txt": "m\n"}, lane="triad-1") + clone = a / "worktrees" / "031-n" / "spec" + b.git("clone", "-q", b.remotes / "triad-spec.git", clone) + cases = ( + (a / "worktrees", f"the assembly's paired root {a}/worktrees could not be listed", False), + (a / "worktrees" / "030-m", + f"the assembly's feature directory {a}/worktrees/030-m could not be listed", False), + (clone, f"the assembly's paired tree directory {clone} could not be searched", True), + ) + for target, words, mine_seen in cases: + os.chmod(target, 0) + try: + proc = handoff(b, "triad-1", a, "unlistable") + rec = b.tool("lanes-edit.sh", "lane-reconcile", "triad-1") + finally: + os.chmod(target, 0o755) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert (f"WRITER {mine} —" in proc.stdout) is mine_seen, (target, proc.stdout) + assert words in proc.stderr, (target, proc.stderr) + assert "may not have been polled" in proc.stderr, (target, proc.stderr) + assert "lifecycle: SWAPPING" in proc.stdout, (target, proc.stdout) + assert f"- NOT POLLED: {words}" in top_block(b, "triad-1"), target + verdict = field(rec.stdout, "VERDICT") + assert verdict and verdict[0] == "indeterminate", (target, rec.stdout) + assert words in verdict[1], (target, verdict) + proc = handoff(b, "triad-1", a, "readable again") + assert proc.returncode == 0, proc.stdout + proc.stderr + assert f"WRITER {mine} — branch 030-m" in proc.stdout, proc.stdout + assert f"PAIRED ROOT, not this lane's: {clone} —" in proc.stdout, proc.stdout + assert "NOT POLLED" not in proc.stdout + proc.stderr + assert "lifecycle: SWAPPED" in proc.stdout, proc.stdout + + +@NEEDS_UPSTREAM +def test_t018_a_tree_of_a_nested_dependency_keeps_the_checkout_add_recorded(triad): + """N5 OF THE PRE-PR REVIEW: a tree `add` made from a dependency nested in a + leg (`--checkout /code/upstream/dep`) is no declared leg's, and + the next handoff re-recorded it under the assembly - the loss (b) fixed for + the legs. A recorded checkout of the tree's own repository is now kept.""" + b, t = triad + dep = t["dep"] + made = b.tool("lane-worktrees", "add", "triad-1", "d1", "--checkout", dep) + assert made.returncode == 0, made.stderr + tree = made.stdout.strip() + assert inventory(b, "triad-1")[tree] == str(dep) + proc = handoff(b, "triad-1", t["assembly"]) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert f"WRITER {tree} —" in proc.stdout, proc.stdout + assert inventory(b, "triad-1")[tree] == str(dep) + + +# ===================================================================== (3) + +@NEEDS_UPSTREAM +def test_t018_reconcile_reports_what_the_legs_hold_and_no_sidecar_names(triad): + """(3) THE LEGS' REGISTRATIONS AND THE PAIRED ROOT ARE READ: a spec tree + made away from every root, a paired code tree, a clone where a paired + worktree belongs and a registration whose directory is gone are each + reported - unmanaged or stale - naming the leg, while a tree the inventory + records is not, and neither leg's own checkout is.""" + b, t = triad + a, code, spec = t["assembly"], t["code"], t["spec"] + away = leg_tree(b, spec, b.projects / "away-spec", "away") + paired = leg_tree(b, code, a / "worktrees" / "010-v" / "code", "010-v") + clone = a / "worktrees" / "010-v" / "spec" + b.git("clone", "-q", b.remotes / "triad-spec.git", clone) + gone = leg_tree(b, code, b.projects / "gone-code", "gone") + shutil.rmtree(gone) + made = b.tool("lane-worktrees", "add", "triad-1", "c1", "--checkout", code) + assert made.returncode == 0, made.stderr + recorded = made.stdout.strip() + + proc = b.tool("lanes-edit.sh", "lane-reconcile", "triad-1") + assert proc.returncode == 0, proc.stderr + rows = reconcile_rows(proc.stdout) + assert rows[real(away)][0] == "unmanaged", rows + assert f"git registers it in {spec}, the spec leg of {a}/project.yaml (opensoft/triad-spec)" \ + in rows[real(away)][1], rows + assert rows[real(paired)][0] == "unmanaged", rows + assert f"the code leg of {a}/project.yaml" in rows[real(paired)][1], rows + assert rows[real(clone)][0] == "unmanaged", rows + assert f"paired feature trees ({a}/worktrees)" in rows[real(clone)][1], rows + assert rows[real(gone)][0] == "stale-registration", rows + assert f"git -C {code} worktree prune" in rows[real(gone)][1], rows + assert rows[real(recorded)][0] == "ok", rows + for not_a_tree in (a, code, spec, a / ".git" / "modules" / "code", a / ".git" / "modules" / "spec", + a / "worktrees" / "010-v"): + assert real(not_a_tree) not in rows, rows + assert field(proc.stdout, "TREES") == ["1 inventoried", "0 dirty or unpushed", + "0 require recovery", "4 unmanaged or stale"], proc.stdout + assert field(proc.stdout, "VERDICT")[0] == "ungraceful-stop", proc.stdout + + +@NEEDS_UPSTREAM +def test_t018_reconcile_pronounces_no_clearance_over_legs_nobody_read(triad): + """A manifest, or the paired root, that could not be read leaves paths + nobody inspected: the verdict is `indeterminate`, never a clearance.""" + b, t = triad + if os.geteuid() == 0: + pytest.skip("root reads a mode-000 file") + a, code = t["assembly"], t["code"] + paired = leg_tree(b, code, a / "worktrees" / "011-u" / "code", "011-u") + os.chmod(a / "project.yaml", 0) + try: + proc = b.tool("lanes-edit.sh", "lane-reconcile", "triad-1") + finally: + os.chmod(a / "project.yaml", 0o644) + assert proc.returncode == 0, proc.stderr + verdict = field(proc.stdout, "VERDICT") + assert verdict[0] == "indeterminate", proc.stdout + assert (f"the legs of the assembly {a} could not be read (its project.yaml is there and " + "unreadable)") in verdict[1], verdict + assert real(paired) not in reconcile_rows(proc.stdout) + # the paired root that cannot be listed: a clone stands in it, which only + # the listing could find (a registered tree is found through its leg) + b.git("worktree", "remove", paired, cwd=code) + b.git("clone", "-q", b.remotes / "triad-code.git", a / "worktrees" / "012-t" / "code") + os.chmod(a / "worktrees", 0) + try: + proc = b.tool("lanes-edit.sh", "lane-reconcile", "triad-1") + finally: + os.chmod(a / "worktrees", 0o755) + assert proc.returncode == 0, proc.stderr + verdict = field(proc.stdout, "VERDICT") + assert verdict[0] == "indeterminate", proc.stdout + assert f"the assembly's paired root {a}/worktrees could not be listed" in verdict[1], verdict + + +@NEEDS_UPSTREAM +def test_t018_reconcile_reports_a_leg_git_cannot_read_as_the_sweep_counts_it(triad): + """A LEG WITH A `.git` OF ITS OWN THAT GIT CANNOT READ was passed over by + the reconciliation IN SILENCE, as a leg that is not checked out is, while + the sweep's `leg` row for it is `unreadable` and counted. Now it is the + sweep's rule and the sweep's word: one TREE row, `unreadable`, that requires + recovery - for a `.git` naming no repository, and for one where git answers + the assembly. Its verdict is no `indeterminate`, so the sweep still lists + and counts that leg (exit 3) rather than refusing on the verdict. Readable + again, or not checked out at all, the leg names nothing.""" + b, t = triad + a, spec = t["assembly"], t["spec"] + gitfile = (spec / ".git").read_text() + + def reconciled_rows() -> tuple: + rec = b.tool("lanes-edit.sh", "lane-reconcile", "triad-1") + assert rec.returncode == 0, rec.stderr + return rec, reconcile_rows(rec.stdout) + + def check(answer: str) -> None: + rec, rows = reconciled_rows() + assert list(rows) == [real(spec)], rows + cls, why = rows[real(spec)] + assert cls == "unreadable", rows + assert why.startswith(f"the spec leg of {a}/project.yaml (opensoft/triad-spec): git could " + "not be read through it (it has a .git of its own and git could not " + "read it: "), why + assert answer in why and f"(git -C {spec} status)" in why, (answer, why) + # N2 of the review of bf2a220: a leg is the assembly's, not a writer's tree + assert why.endswith("before relaunching a writer onto a tree of its repository"), why + assert field(rec.stdout, "TREES") == ["0 inventoried", "0 dirty or unpushed", + "1 require recovery", "0 unmanaged or stale"], rec.stdout + assert field(rec.stdout, "VERDICT")[0] == "ungraceful-stop", rec.stdout + sweep = b.sweep("triad-1", "--dry-run", "--porcelain") + assert sweep.returncode == 3, sweep.stdout + sweep.stderr + leg = porcelain_rows(sweep.stdout, "leg")[str(spec)] + assert leg["disposition"] == "unreadable" and leg["retire"] == "retire", leg + assert "git could not be read through it" in leg["why"] and answer in leg["why"], leg + + (spec / ".git").write_text("gitdir: /nonexistent/t018-spec\n") + check(git_says(b, spec)) + (spec / ".git").unlink() + (spec / ".git").mkdir() + check(f"git answers {real(a)} there") + shutil.rmtree(spec / ".git") + (spec / ".git").write_text(gitfile) + for state in ("readable again", "deinitialized"): + if state == "deinitialized": + b.git("submodule", "deinit", "-q", "spec", cwd=a) + assert not any(spec.iterdir()) + rec, rows = reconciled_rows() + assert real(spec) not in rows, (state, rows) + assert field(rec.stdout, "TREES")[2] == "0 require recovery", (state, rec.stdout) + + +@NEEDS_UPSTREAM +def test_t018_the_handoff_reports_a_leg_git_cannot_read_as_the_sweep_counts_it(triad): + """THE SAME SILENT SKIP IN `lane-handoff`: a leg with a `.git` of its own + that git cannot read was passed over as a leg that is not checked out is, + so the handoff said nothing of it, and a tree `add` made from that leg was + re-recorded under the ASSEMBLY - the loss (2) fixed. Now it is the sweep's + rule and word: one `- UNREADABLE LEG` line in WRITERS, a note on stderr and + a count, for a `.git` naming no repository and for one where git answers + the assembly. The lifecycle is not held for the leg itself (the + reconciliation's verdict is not `indeterminate` for it either): with no + tree of that leg the lane is SWAPPED. A tree of that leg keeps the checkout + `add` recorded - named, NOT recorded, and the lane stays SWAPPING for it - + while a tree of the assembly is recorded as always. Readable again, the + leg names nothing and its tree is recorded under it.""" + b, t = triad + a, spec = t["assembly"], t["spec"] + made = b.tool("lane-worktrees", "add", "triad-1", "root") + assert made.returncode == 0, made.stderr + lane_root = Path(made.stdout.strip()) + gitfile = (spec / ".git").read_text() + words = (f"the spec leg of {a}/project.yaml (opensoft/triad-spec): git could not be read " + "through it (it has a .git of its own and git could not read it: ") + + def check(answer: str, trees: tuple, unplaced: tuple, state: str) -> None: + proc = handoff(b, "triad-1", a, "unreadable leg") + assert proc.returncode == 0, proc.stdout + proc.stderr + assert f"the leg {spec} is unreadable: {words}" in proc.stderr, proc.stderr + assert answer in proc.stderr.split(f"the leg {spec} is unreadable: ", 1)[1], proc.stderr + assert f"legs: 1 of the assembly's legs under {a} unreadable" in proc.stderr, proc.stderr + assert f"writers polled: {len(trees)} under {a}" in proc.stderr, proc.stderr + for tree in trees: + assert f"WRITER {tree} —" in proc.stdout, proc.stdout + missed = f"the worktree inventory entry for {tree} was NOT written: " + if tree in unplaced: + assert (missed + "the assembly has 1 unreadable leg(s), each named by an " + "UNREADABLE LEG line in the top block") in proc.stderr, proc.stderr + else: + assert missed not in proc.stderr, proc.stderr + assert f"lifecycle: {state}" in proc.stdout, proc.stdout + assert "NOT POLLED" not in proc.stdout + proc.stderr + writers = top_block(b, "triad-1").split("**WRITERS at ", 1)[1] + line = next((ln for ln in writers.splitlines() if ln.startswith("- UNREADABLE LEG: ")), "") + assert line.startswith(f"- UNREADABLE LEG: `{spec}` is unreadable, {words}"), writers + assert answer in line and f"(`git -C {spec} status`)" in line, line + assert writers.count("- UNREADABLE LEG: ") == 1, writers + # N2 of the review of bf2a220: a leg is the assembly's, not a writer's tree + assert line.endswith("before relaunching a writer onto a tree of its repository."), line + assert proc.stderr.split(f"the leg {spec} is unreadable: ", 1)[1].split("\n", 1)[0] \ + .endswith("before relaunching a writer onto a tree of its repository."), proc.stderr + # N1: the leg's line says what was withheld, and a withheld tree's own line + # says it is NOT recorded, where a recorded tree's line does not + if unplaced: + assert (f"the {len(unplaced)} tree(s) this handoff could not place are named " + "above, each marked (NOT recorded)") in line, line + else: + assert ("every tree this handoff polled could still be placed, so none is " + "withheld for it") in line, line + for tree in trees: + assert (f"- `{tree}` (NOT recorded) — branch `" in writers) is (tree in unplaced), \ + (tree, writers) + assert (f"- `{tree}` — branch `" in writers) is (tree not in unplaced), (tree, writers) + + (spec / ".git").write_text("gitdir: /nonexistent/t018-spec\n") + check(git_says(b, spec), (lane_root,), (), "SWAPPED") + assert inventory(b, "triad-1") == {str(lane_root): str(a)} + rec = b.tool("lanes-edit.sh", "lane-reconcile", "triad-1") + assert reconcile_rows(rec.stdout)[real(spec)][0] == "unreadable", rec.stdout + assert field(rec.stdout, "TREES")[2] == "1 require recovery", rec.stdout + assert field(rec.stdout, "VERDICT")[0] == "resumable", rec.stdout + (spec / ".git").unlink() + (spec / ".git").mkdir() + check(f"git answers {real(a)} there", (lane_root,), (), "SWAPPED") + shutil.rmtree(spec / ".git") + (spec / ".git").write_text(gitfile) + + made = b.tool("lane-worktrees", "add", "triad-1", "s1", "--checkout", spec) + assert made.returncode == 0, made.stderr + lane_spec = Path(made.stdout.strip()) + want = {str(lane_spec): str(spec), str(lane_root): str(a)} + assert inventory(b, "triad-1") == want + (spec / ".git").write_text("gitdir: /nonexistent/t018-spec\n") + check(git_says(b, spec), (lane_spec, lane_root), (lane_spec,), "SWAPPING") + assert inventory(b, "triad-1") == want + + (spec / ".git").write_text(gitfile) + proc = handoff(b, "triad-1", a, "readable again") + assert proc.returncode == 0, proc.stdout + proc.stderr + assert f"the leg {spec} is unreadable" not in proc.stderr, proc.stderr + assert "of the assembly's legs under" not in proc.stderr, proc.stderr + assert "UNREADABLE LEG" not in top_block(b, "triad-1") + assert "lifecycle: SWAPPED" in proc.stdout, proc.stdout + assert inventory(b, "triad-1") == want + + +@NEEDS_UPSTREAM +def test_t018_an_unreadable_leg_withholds_no_tree_of_another_repository(triad): + """S1 OF THE REVIEW OF bf2a220: while a leg was unreadable, the handoff + withheld EVERY tree that was not of the assembly's own repository - a + worktree of an unrelated repository in the lane's own root included, as + lane openRepoTools-3's root holds today - so the lane stayed SWAPPING on + every handoff and those trees' records went stale for as long as the leg + stayed broken. Now only a tree that could be of the leg's repository is + withheld: one whose common git directory lies inside the assembly, or one + the inventory records under the unreadable leg. A worktree of a third + repository, recorded before or new, is recorded under the lane's `dir` as + when every leg reads, and with nothing else withheld the lane is SWAPPED.""" + b, t = triad + a, spec = t["assembly"], t["spec"] + third = b.projects / "third" + b.git("clone", "-q", b.seeded("third", {"t.txt": "a third repository\n"}), third) + root = b.projects / ".lane-worktrees" / "triad-1" + before = leg_tree(b, third, root / "nw-before", "nw-before") + proc = handoff(b, "triad-1", a, "every leg reads") + assert proc.returncode == 0, proc.stdout + proc.stderr + assert inventory(b, "triad-1") == {str(before): str(a)} + + (spec / ".git").write_text("gitdir: /nonexistent/t018-spec\n") + after = leg_tree(b, third, root / "nw-after", "nw-after") + proc = handoff(b, "triad-1", a, "unreadable leg") + assert proc.returncode == 0, proc.stdout + proc.stderr + assert f"the leg {spec} is unreadable: " in proc.stderr, proc.stderr + assert f"writers polled: 2 under {a}" in proc.stderr, proc.stderr + for tree in (before, after): + assert f"WRITER {tree} — branch {tree.name}" in proc.stdout, proc.stdout + assert f"the worktree inventory entry for {tree} was NOT written" not in proc.stderr, \ + proc.stderr + assert "lifecycle: SWAPPED" in proc.stdout, proc.stdout + writers = top_block(b, "triad-1").split("**WRITERS at ", 1)[1] + assert "(NOT recorded)" not in writers, writers + assert ("every tree this handoff polled could still be placed, so none is withheld " + "for it") in writers, writers + assert inventory(b, "triad-1") == {str(before): str(a), str(after): str(a)} + + # A RECORD NAMING THE UNREADABLE LEG still withholds its tree, whatever + # repository the tree's own common directory says it is of. + named = leg_tree(b, third, root / "nw-named", "nw-named") + rec = b.tool("lanes-edit.sh", "set-lane-tree", "triad-1", named, "--checkout", spec) + assert rec.returncode == 0, rec.stderr + proc = handoff(b, "triad-1", a, "unreadable leg, and a record naming it") + assert proc.returncode == 0, proc.stdout + proc.stderr + assert (f"the worktree inventory entry for {named} was NOT written: the assembly has 1 " + "unreadable leg(s)") in proc.stderr, proc.stderr + for tree in (before, after): + assert f"the worktree inventory entry for {tree} was NOT written" not in proc.stderr, \ + proc.stderr + assert "lifecycle: SWAPPING" in proc.stdout, proc.stdout + writers = top_block(b, "triad-1").split("**WRITERS at ", 1)[1] + assert f"- `{named}` (NOT recorded) — branch `nw-named`" in writers, writers + for tree in (before, after): + assert f"- `{tree}` — branch `{tree.name}`" in writers, writers + assert ("the 1 tree(s) this handoff could not place are named above, each marked " + "(NOT recorded)") in writers, writers + assert inventory(b, "triad-1") == {str(before): str(a), str(after): str(a), + str(named): str(spec)} + + +@NEEDS_UPSTREAM +def test_t018_a_tree_git_cannot_read_in_an_assembly_is_named_and_holds_the_lane_swapping(triad): + """S2 OF THE REVIEW OF bf2a220: the poll passed over IN SILENCE any tree + with a `.git` git cannot read. With the spec leg's module directory + deleted after `add --checkout spec`, every tree of that leg - the one + `add` made, and a paired spec tree the inventory names - vanished from + WRITERS and from its count, and the lane went SWAPPED. Now each is listed + and counted, `UNREADABLE` and `(NOT recorded)`, its record is kept, and + the lane stays SWAPPING. Scoped to assemblies: a single repository's tree + that git cannot read is passed over, as it always was.""" + b, t = triad + a, spec = t["assembly"], t["spec"] + made = b.tool("lane-worktrees", "add", "triad-1", "s1", "--checkout", spec) + assert made.returncode == 0, made.stderr + lane_spec = Path(made.stdout.strip()) + made = b.tool("lane-worktrees", "add", "triad-1", "root") + assert made.returncode == 0, made.stderr + lane_root = Path(made.stdout.strip()) + paired_spec = leg_tree(b, spec, a / "worktrees" / "040-p" / "spec", "040-p") + b.commit(paired_spec, "the feature's spec", {"p.md": "p\n"}, lane="triad-1") + proc = handoff(b, "triad-1", a, "every leg reads") + assert proc.returncode == 0, proc.stdout + proc.stderr + want = {str(lane_spec): str(spec), str(lane_root): str(a), str(paired_spec): str(spec)} + assert inventory(b, "triad-1") == want + + shutil.rmtree(a / ".git" / "modules" / "spec") + proc = handoff(b, "triad-1", a, "the spec leg's module directory is gone") + assert proc.returncode == 0, proc.stdout + proc.stderr + assert f"the leg {spec} is unreadable: " in proc.stderr, proc.stderr + assert f"writers polled: 3 under {a}" in proc.stderr, proc.stderr + assert f"WRITER {lane_root} — branch root" in proc.stdout, proc.stdout + block = top_block(b, "triad-1") + assert f"(3 found under `{a}`)" in block, block + writers = block.split("**WRITERS at ", 1)[1] + for tree in (lane_spec, paired_spec): + assert (f"WRITER {tree} — UNREADABLE: it has a .git of its own and git could not " + "read it: ") in proc.stdout, proc.stdout + assert (f"the worktree inventory entry for {tree} was NOT written: it has a .git of " + "its own and git could not read it") in proc.stderr, proc.stderr + assert (f"- `{tree}` (NOT recorded) — UNREADABLE: it has a .git of its own and git " + "could not read it (") in writers, writers + assert f"(`git -C {tree} status`)" in writers, writers + assert f"- `{lane_root}` — branch `root`" in writers, writers + assert ("the 2 tree(s) this handoff could not place are named above, each marked " + "(NOT recorded)") in writers, writers + assert "lifecycle: SWAPPING" in proc.stdout, proc.stdout + held = proc.stderr.split("the worktree inventory was not recorded for ", 1)[1].split("\n", 1)[0] + assert str(lane_spec) in held and str(paired_spec) in held and str(lane_root) not in held, held + assert inventory(b, "triad-1") == want + + # A SINGLE REPOSITORY PASSES OVER SUCH A TREE, AS IT ALWAYS DID. + b.origin("plain") + s = b.projects / "plain" + b.git("clone", "-q", b.remotes / "plain.git", s) + b.commit(s, "seed", {"README.md": "plain\n"}) + b.git("push", "-q", "origin", "main", cwd=s) + b.lanes("opensoft/plain", s, "plain-1") + made = b.tool("lane-worktrees", "add", "plain-1", "w") + assert made.returncode == 0, made.stderr + w = Path(made.stdout.strip()) + admin = (w / ".git").read_text().split("gitdir:", 1)[1].strip() + shutil.rmtree(admin) + proc = handoff(b, "plain-1", s, "a tree git cannot read") + assert proc.returncode == 0, proc.stdout + proc.stderr + assert f"writers polled: 0 under {s}" in proc.stderr, proc.stderr + assert "UNREADABLE" not in proc.stdout + proc.stderr + assert "NOT recorded" not in proc.stdout + proc.stderr + top_block(b, "plain-1") + assert "lifecycle: SWAPPED" in proc.stdout, proc.stdout + + +@NEEDS_UPSTREAM +def test_t018_a_leg_tree_add_made_is_withheld_where_the_legs_git_is_an_empty_directory(triad): + """N4 OF THE REVIEW OF bf2a220: the empty-`.git`-directory case was run + only with no tree of that leg, and its path differs from the gitfile + case's: git answers the ASSEMBLY in the leg, so the common directory of + the checkout the tree's record names is the assembly's own `.git`, not + nothing. A tree `add --checkout spec` made is still withheld - named, + marked `(NOT recorded)`, its record kept under the leg and never moved to + the assembly - and the lane stays SWAPPING; readable again, the tree is + recorded under its leg and the lane is SWAPPED.""" + b, t = triad + a, spec = t["assembly"], t["spec"] + made = b.tool("lane-worktrees", "add", "triad-1", "s1", "--checkout", spec) + assert made.returncode == 0, made.stderr + lane_spec = Path(made.stdout.strip()) + want = {str(lane_spec): str(spec)} + assert inventory(b, "triad-1") == want + gitfile = (spec / ".git").read_text() + (spec / ".git").unlink() + (spec / ".git").mkdir() + # the path this case takes: in the leg, git answers the assembly + assert real(b.git("rev-parse", "--show-toplevel", cwd=spec)) == real(a) + assert real(spec / b.git("rev-parse", "--git-common-dir", cwd=spec)) == real(a / ".git") + proc = handoff(b, "triad-1", a, "the spec leg's .git is an empty directory") + assert proc.returncode == 0, proc.stdout + proc.stderr + assert f"the leg {spec} is unreadable: " in proc.stderr, proc.stderr + assert f"git answers {real(a)} there" in proc.stderr, proc.stderr + assert f"WRITER {lane_spec} — branch s1" in proc.stdout, proc.stdout + assert (f"the worktree inventory entry for {lane_spec} was NOT written: the assembly has 1 " + "unreadable leg(s)") in proc.stderr, proc.stderr + assert "lifecycle: SWAPPING" in proc.stdout, proc.stdout + writers = top_block(b, "triad-1").split("**WRITERS at ", 1)[1] + assert f"- `{lane_spec}` (NOT recorded) — branch `s1`" in writers, writers + assert ("the 1 tree(s) this handoff could not place are named above, each marked " + "(NOT recorded)") in writers, writers + assert inventory(b, "triad-1") == want + + shutil.rmtree(spec / ".git") + (spec / ".git").write_text(gitfile) + proc = handoff(b, "triad-1", a, "readable again") + assert proc.returncode == 0, proc.stdout + proc.stderr + assert "UNREADABLE LEG" not in top_block(b, "triad-1") + assert "(NOT recorded)" not in top_block(b, "triad-1") + assert "lifecycle: SWAPPED" in proc.stdout, proc.stdout + assert inventory(b, "triad-1") == want + + +# ===================================================== the single repository + +def single_repository_decoys(bench: Bench) -> list: + """THE SCENARIO WHOSE OUTPUTS MAY NOT MOVE, laid out to look like a triad: + a repository with a submodule that is NO leg, a worktree of that submodule + under `worktrees//code` and one of the repository itself under + `worktrees//spec`, and a tree under the lane's own root recorded + with the SUBMODULE as its checkout - first with no `project.yaml`, then with + one naming no leg but the checkout itself. The handoff's output, its top + block and inventory, and the reconciliation before and after it.""" + b = bench + out = [] + dep = b.seeded("dep", {"d.txt": "a dependency\n"}) + + def scenario(name: str, manifest: str | None) -> None: + lane = f"{name}-1" + b.origin(name) + s = b.projects / name + b.git("clone", "-q", b.remotes / f"{name}.git", s) + files = {"README.md": f"{name}\n", ".gitignore": "worktrees/\n"} + if manifest is not None: + files["project.yaml"] = manifest + b.commit(s, "seed", files) + b.git("submodule", "add", "-q", dep, "vendor/dep", cwd=s) + b.commit(s, "a submodule that is no leg") + b.git("push", "-q", "origin", "main", cwd=s) + b.lanes(f"opensoft/{name}", s, lane) + vendor = s / "vendor" / "dep" + decoy_code = leg_tree(b, vendor, s / "worktrees" / "001-d" / "code", "001-d") + b.commit(decoy_code, "decoy code", {"x.txt": "x\n"}, lane=lane) + decoy_spec = leg_tree(b, s, s / "worktrees" / "001-d" / "spec", "001-d") + (decoy_spec / "y.md").write_text("y\n") + mine = leg_tree(b, vendor, b.projects / ".lane-worktrees" / lane / "dep", "dep") + b.commit(mine, "lane work in the dependency", {"m.txt": "m\n"}, lane=lane) + rec = b.tool("lanes-edit.sh", "set-lane-tree", lane, mine, "--checkout", vendor) + assert rec.returncode == 0, rec.stderr + out.append((f"{name}: lane-reconcile before", reconciled(b, lane))) + out.append((f"{name}: lane-handoff", shown(handoff(b, lane, s), b))) + out.append((f"{name}: lane-handoff: top block", normalise(top_block(b, lane), b))) + trees = b.tool("lanes-edit.sh", "lane-trees", lane) + trees.stdout = "".join(sorted(trees.stdout.splitlines(keepends=True), + key=lambda line: normalise(line, b))) + out.append((f"{name}: lane-handoff: inventory", shown(trees, b))) + out.append((f"{name}: lane-reconcile after", reconciled(b, lane))) + + scenario("repoD", None) + scenario("repoQ", ("schema_version: 1\nkind: project-manifest\nid: repoQ\nlegs:\n" + " - role: assembly\n repository: opensoft/repoQ\n path: \".\"\n")) + return out + + +def reconciled(b: Bench, lane: str) -> str: + """`lane-reconcile`, its TREE rows in a stable order: the inventoried ones + come in the order of their sidecars' file names, a checksum of the bench's + own path, so every row is sorted by its normalised text, in place.""" + proc = b.tool("lanes-edit.sh", "lane-reconcile", lane) + lines = proc.stdout.splitlines(keepends=True) + idx = [i for i, ln in enumerate(lines) if ln.startswith("TREE" + US)] + for i, ln in zip(idx, sorted((lines[i] for i in idx), key=lambda ln: normalise(ln, b))): + lines[i] = ln + proc.stdout = "".join(lines) + return shown(proc, b) + + +@pytest.mark.skipif(not sys.platform.startswith("linux"), + reason="the golden was captured on Linux, as tests/test_triad_lane_tooling.py's") +def test_t018_single_repository_handoff_and_reconcile_are_c45a452s_byte_for_byte(tmp_path): + """THE INVARIANT, ON A REPOSITORY LAID OUT TO LOOK LIKE A TRIAD: with no + `project.yaml` naming a leg, a `worktrees//code` holding a + submodule's worktree is not polled or reported as a paired tree, and a tree + of a submodule is recorded under the lane's checkout, exactly as + `c45a452`'s tools did - every byte of every output equal to the stored + golden this module's `--capture` wrote from them.""" + assert GOLDEN.is_file(), f"{GOLDEN} is missing; it is generated from c45a452's tools" + want = parse_golden(GOLDEN.read_text(encoding="utf-8")) + got = single_repository_decoys(Bench(tmp_path / "bench", REPO)) + assert [k for k, _ in got] == [k for k, _ in want] + for (key, have), (_k, gold) in zip(got, want): + if have != gold: + diff = "\n".join(difflib.unified_diff(gold.splitlines(), have.splitlines(), + f"c45a452: {key}", f"now: {key}", lineterm="")) + raise AssertionError(f"the single-repository output of '{key}' moved:\n{diff}") + + +# ===================================================================== capture + +def capture(tools: Path, golden: Path) -> None: + """Write the golden file from the tools in `tools` - run ONCE, against + `c45a452`'s (`git archive c45a452 | tar -x -C `), and committed.""" + with tempfile.TemporaryDirectory(prefix="t018-handoff-capture.") as tmp: + text = render_golden(single_repository_decoys(Bench(Path(tmp) / "bench", tools))) + golden.parent.mkdir(parents=True, exist_ok=True) + golden.write_text(text, encoding="utf-8") + + +if __name__ == "__main__": + if len(sys.argv) in (3, 4) and sys.argv[1] == "--capture": + capture(Path(sys.argv[2]).resolve(), Path(sys.argv[3]) if len(sys.argv) == 4 else GOLDEN) + sys.exit(0) + sys.stderr.write("usage: test_triad_lane_tooling_handoff.py --capture []\n") + sys.exit(64)