Skip to content

lane-worktrees: close #170's --yes data-loss and gate holes, self-check every removal, switch --yes back on - #174

Merged
brettheap merged 81 commits into
mainfrom
feat/lane-worktrees-residue
Oct 7, 2026
Merged

brettheap merged 81 commits into
mainfrom
feat/lane-worktrees-residue

Conversation

@brettheap

@brettheap brettheap commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

What and why

This is the first of two PRs for #170, stacked on #169 (feat/lane-worktrees-report). It closes every path in #170 that could delete, publish or let #163's gate pass when it should not, and switches lane-worktrees sweep --yes and --expire --yes back on (c550e89). The nine commits after c550e89 take Copilot's first round on this PR (below); none of them brings the switch back. LANE_WORKTREES_ENABLE_YES appears nowhere in the code now; the one mention left is the test asserting the fixture does not set it.

Lane openRepoTools-3 opened this PR. Since 2026-10-06T16:20Z lane openRepoTools-1 is its writer (Brett Heap's word of 2026-10-06: "we can use openRepoTools-1 to help if you can send tasks there too").

--yes has been refused since 5f4bd84 unless LANE_WORKTREES_ENABLE_YES=1 was set. The reviews of #168 and #169 found these data-loss paths:

  • a clone's tag-only commits;
  • a submodule's unpublished branches and stash;
  • a mirror-style fetch config together with --prune;
  • scratch directories holding trees;
  • bundle-only rescues missing from the ledger;
  • edits hidden by skip-worktree or assume-unchanged;
  • a clone inside a venv;
  • prune dropping the only pointer to detached commits;
  • commits that only a reflog names;
  • untracked secrets pushed by the WIP rescue;
  • --no-verify skipping the git-lfs upload;
  • a teammate's open PR branch receiving the lane's commits;
  • a register LANDED line overriding a live gh OPEN.

There were also gate-correctness holes. An unreadable lane root, registration list or inventory row read as "nothing there". Other lanes' claims in a nested estate were never read. The --branches counts were off in both directions.

Every item now has a regression case. Each case was proved to fail against the base lane-worktrees (45fa45d) and to pass here.

Each removal also runs a self-check first. It asks git directly whether every commit the removal would take is on origin, in a ref that stays, or in a bundle this run verified. The commits it checks are:

  • for a worktree, its HEAD and its admin logs/HEAD, plus the branch reflog when the branch is deleted;
  • for a clone, every ref and reflog;
  • for a pruned registration, its admin HEAD and reflog.

The check runs git rev-list <those> --not --remotes=origin --branches --tags --glob=refs/stash <verified pushes> <bundle heads>. If anything comes back, the sweep refuses with exit 2 and writes a DISPOSITION.md line. Acts already done stay recorded, and nothing after the refusal is acted on. The commit-level reason for each fix is in its commit body.

What guards each kind of removal, after round 1:

  • a worktree, a clone, a pruned registration and, since 702c2df, a --branches delete (its tip and its reflog): the commit-level self-check above;
  • scratch, a cache, and an unmarked tmp.* sandbox hold no commit of their own. Right before the rmtree they are checked to hold no repository (any .git); a directory inside that cannot be read counts as one (573a420, 6436450, item 7);
  • a suite-marked sandbox (pytest's .lock, run.sh's marker or layout, with its pid gone) is removed on that mark. Its repositories are the suite's fixtures;
  • --expire removes only an archive whose ledger is whole and whose every rescue is on origin at its SHA, and never one with a bundle: row;
  • a remote branch is deleted only under a lease on the judged SHA, merged by PR evidence, and never while gh answers its PR OPEN.

The two defaults applied (stated for Brett Heap to overrule)

  1. Item 1, /tmp/tmp.* sandboxes. A tmp.* directory is removed only on proof, in one of two forms:

    • a suite's mark whose pid is gone: pytest's .lock or the .openrepotools-run marker tests/run.sh now writes, each naming a pid (since 3cdd387 an empty or malformed one is no mark), or run.sh's run-root layout (basetemp/ beside tmp/);
    • no mark at all, but nothing written in it for aging_days (14).

    Any other tmp.* is listed and left alone. A directory a live process stands in, holds open or names in its environment is never touched, as before. Since 6436450 an unmarked tmp.* that holds a repository is listed whatever its age: nothing says its repositories are fixtures. This is stricter than the default, so it stays within it.

    The literal default was "a marker proves it is a suite's, OR its owning pid is gone AND it is older than 14 days". An unmarked mktemp -d directory records no pid, so I read its second half as "unmarked and untouched for 14 days". To overrule: the stricter reading never removes an unmarked tmp.*. That is a one-line change in sandbox_items.

  2. Item 2, unreadable /proc entries. Another uid's unreadable entry is ignored, since a writer runs as us. An unreadable entry of our uid is read as unknown liveness: the tree it could be in is kept, and --yes takes it with no other tree. "Could be in" means one of three things:

    • its command line names a path in the tree;
    • its parent's cwd is in the tree;
    • its own status cannot be read either, in which case it could be in any tree.

    On Eagle the always-present unreadable ones are 3 ssh-agent processes, whose command lines name no tree. Treating them as "anywhere" would keep every tree on every sweep. The table also lists every such pid in a note line.

Items, commits and tests

All tests are in tests/test_lane_worktrees.py unless noted. "Fails at base" means the case ran against 45fa45d's lane-worktrees (and lanes-edit.sh) with this branch's tests, in the CI proof runs below.

item commit test fails at base
1 tmp.* only on proof 2eb5216 test_a_tmp_dir_with_no_mark_of_a_suite_is_listed_until_it_is_old yes
2 unreadable /proc 2eb5216 test_an_unreadable_process_of_this_account_keeps_the_tree_it_names yes
4 tag-only commits 85365fd test_a_clones_tag_only_commits_keep_it yes
5 submodule branches/stash 85365fd test_a_submodules_unpublished_branch_or_stash_keeps_its_tree[branch,stash] yes (both)
6 mirror refspec + --prune 040b12b test_a_mirror_style_fetch_refspec_prunes_no_local_branch yes
7 scratch holding trees 1e583d1 test_scratch_holding_a_repository_is_kept yes
8 bundle-only rescue in the ledger 31bbfb1 test_an_archive_whose_bundle_is_the_only_copy_never_expires yes
A1 register fetched under --yes 4a79c8c test_yes_reads_the_register_now_or_refuses[origin-unreachable,LANES_NO_FETCH-inherited] yes (both)
A2 skip-worktree / assume-unchanged 85365fd test_an_edit_git_status_hides_keeps_the_tree[--skip-worktree,--assume-unchanged] yes (both)
A2 guard: a sparse checkout is no edit 85365fd test_a_sparse_checkouts_absent_files_are_no_edit no, by design (it keeps A2 from over-reaching)
A3 clone inside a venv 85365fd test_a_clone_inside_a_venv_keeps_its_tree yes
A4 prune drops detached commits 31bbfb1 test_a_pruned_registrations_detached_commit_is_rescued_first yes
A5 reflog-only commits 31bbfb1 test_a_commit_only_the_reflog_names_is_bundled_before_removal yes
A6 ignored file under a build-named ancestor 8ec5a9a test_an_ignored_file_beneath_a_build_named_directory_is_archived yes
A7 teammate's open PR branch d32be5d test_unreviewed_commits_never_go_onto_somebody_elses_branch[open-pr,another-lanes-trailer,gh-unreadable] yes (all three)
A8 LANDED vs a live gh OPEN d32be5d test_a_landed_line_never_deletes_a_branch_gh_answers_open yes
A9 untracked files bundle-only 31bbfb1 test_untracked_files_go_to_the_bundle_never_to_origin[True,False], test_push_untracked_pushes_them yes (all three)
A10 --no-verify vs git-lfs d4724a2 test_a_push_runs_the_hooks_where_git_lfs_is_configured[True] ([False] is its control) yes ([True])
A11 dependents anywhere, partial scan 4b65c30, 76011d9, 6ef7c87 test_a_dependent_anywhere_in_the_estate_makes_a_clone_load_bearing, test_a_partial_dependents_scan_removes_no_clone yes (both)
A11 guard: uv's .git marker file 6ef7c87 test_a_dot_git_file_that_is_no_pointer_leaves_the_scan_whole not applicable: it guards 4b65c30's own walk, which base does not have
B4 misspelt --live → 64 741b407 test_a_live_path_that_names_no_tree_is_a_usage_error yes
B5 writer count for own records 741b407 test_trees_this_session_recorded_want_the_writer_count yes
B6 stop on NOT WRITTEN 741b407 test_the_first_register_line_refused_stops_the_sweep yes
G1 unreadable lane root d71569f test_a_lane_root_that_cannot_be_listed_refuses_the_dry_run yes
G5 unreadable registrations d71569f test_registrations_that_cannot_be_read_refuse_the_dry_run yes
G6 other lanes' claims in a nested estate af80118 test_another_lanes_claim_is_read_wherever_its_control_root_is[nested-in-the-estate,recorded-in-the-register] yes (both)
G2 lane-owned open PR branch counted c6ab9f8 test_a_lane_owned_open_pr_branch_origin_lacks_is_still_counted yes
G7 published under its own name c6ab9f8 test_a_branch_published_under_its_own_name_is_not_unfinished yes
G9 inventory row with no id 08ad126 test_an_inventory_row_with_no_id_refuses_the_dry_run yes
G13 pathspec-check fails closed 5ba0c96 tests/test_lane_helpers.sh #162 block: pathspec-check ':(bogus)x' exits 2 and says the read failed; that commit exits 2, says so and commits nothing yes: the 4 refusal assertions are the shell suite's only failures at 45fa45d (4436 passed, 4 failed). "Commits nothing" holds there too, because base's commit fails later, at the add
self-check before each removal 31bbfb1 test_the_self_check_refuses_a_removal_that_would_lose_a_commit yes
--yes switched back on c550e89 test_yes_needs_no_enabling_variable yes

Some commits only support the items above:

  • a6fb64b and 6404f1c are the manual (docs/README-lanes.md).
  • 7e1865a holds the existing cases to the new behaviour: the register fetch under --yes, untracked files to the bundle, and B5 for a record this session wrote.
  • 76011d9 limits the whole-estate walk to where a removal deletes a directory, meaning a clone the table could take or --include-scratch. On Eagle that walk covers 683,000 directories (397 s cold). A worktree's removal deletes no objects, so the estate's shape is enough there.

Copilot round 1 on this PR, and one gap found auditing the self-check

All eight threads were real defects in this PR's own code; each is fixed with a case. "Red" means the case failed with that commit's lane-worktrees (and lanes-edit.sh) and this branch's tests, in proof run 37500368196 (below).

thread what it found commit test (tests/test_lane_worktrees.py) red at c550e89 red at 45fa45d
r4194736582 tree_guards a submodule's commit that only its reflog names went with the tree 64710fb test_a_submodule_commit_only_its_reflog_names_keeps_the_tree yes yes
r4194736644 _tar an ignored file under a cache- or venv-named ancestor was selected, then dropped from the archive, which still verified 968f29d test_an_ignored_file_beneath_a_cache_or_venv_named_directory_is_archived[node_modules,.venv] yes (both) yes (both)
r4194736696 _caches --include-caches removed a .venv holding a clone (pip install -e git+) 573a420 test_a_clone_inside_a_venv_keeps_its_tree (now also --include-caches --yes) yes yes
r4194736746 _loss_plan a clone's reflog directory that could not be listed shrank the loss plan in silence 4eb9cbd test_a_clones_reflogs_that_cannot_be_listed_remove_nothing yes yes
r4194736789 _act_item --branches --yes deleted a branch, and its reflog, with no loss plan, bundle or self-check 702c2df test_a_branch_deletes_reflog_only_commit_is_bundled_first, test_the_self_check_refuses_a_branch_delete_that_would_lose_a_commit yes (both) yes (both)
r4194736836 _wip_rescue which files were untracked came from the table's status, so a key file arriving later was pushed 8fdb336 test_an_untracked_file_that_arrives_after_the_table_is_never_pushed yes yes
r4194736875 lost_tips an annotated tag object was always "lost", so the self-check refused, and halted the sweep on, every clone with one 4ea93db test_a_clones_annotated_tag_neither_halts_the_sweep_nor_is_lost[only-in-the-clone,on-origin-too] yes (both) only-in-the-clone yes (the tag's message was lost); on-origin-too passes there, as it should: the halt came with this PR's self-check
r4194736925 _wip_rescue untracked git-lfs content went to its only-copy bundle as a pointer 8fdb336 test_untracked_git_lfs_content_keeps_the_tree[seen-by-the-table,arrived-after-it] yes (both) yes (both)
(audit of "a check before every removal") sandbox_items an unmarked tmp.* aged past 14 days was removed with a repository and its unpushed commit inside 6436450 test_an_old_unmarked_tmp_dir_holding_a_repository_is_listed_never_removed yes yes

685cbe3 is the manual and --help for all of these. Fix sketches:

  • tag objects: kept where origin holds that very tag object (ls-remote --tags), else bundled with its message;
  • untracked files: the pushed snapshot is always tracked-only unless --push-untracked, and what the full snapshot holds beyond it goes to the bundle;
  • untracked git-lfs content: keeps its tree. That is the "refuse removal" option, checked both when the table is built and at the act, before anything is pushed.

Copilot round 2 on this PR

There were ten threads. Nine were real defects in this PR's code and are fixed, each with a case. One is a cost trade-off and is filed in #176. "Red" means the case failed with lane-worktrees at 685cbe3 (the head before the round) and this branch's tests, in proof run 37506230403.

thread what it found commit test (tests/test_lane_worktrees.py) red at 685cbe3
r4198393067 tree_guards a DEINITIALIZED submodule's repository, kept under <gitdir>/modules/ with a branch no remote held, went with the tree 9eb473a, c538ec1 test_a_deinitialized_submodules_kept_repository_keeps_the_tree yes
r4198393140 tree_guards a skip-worktree edit in a submodule read as clean and went with the tree b266ce2 test_a_submodules_hidden_edit_keeps_the_tree yes
r4198393195 nested_repos the walk that proves a removal takes no repository passed over cache-named directories 72970e2 test_a_repository_under_a_cache_named_directory_keeps_the_scratch yes
r4198393237 gather the whole-estate walk's .lane-state finds were thrown away, so a claim inside a repository was missed fed0e5c test_another_lanes_claim_inside_a_repository_is_read_before_a_clone_goes yes
r4198393296 judge, _decide a LANDED line plus gh's OPEN gave "moved", and push+remove pushed onto the open PR's branch 4fba346 test_a_landed_line_never_lets_commits_onto_a_branch_gh_answers_open yes
r4198393353 _act_prune a gone registration's submodule repositories (<admin>/modules/) were pruned unread a03f5df, c538ec1, 499d2ab test_a_gone_worktrees_kept_submodule_repository_is_never_pruned yes
r4198393416 _act_tree a clone's local git-lfs store went when a bundle, which carries pointers only, was a commit's only copy de45178 test_a_clones_local_lfs_store_keeps_it_when_a_bundle_would_be_the_only_copy yes
r4198393482 sandbox_provenance an empty .lock counted as a suite's mark 3cdd387 test_a_suite_mark_that_names_no_pid_is_no_proof[.lock,.openrepotools-run] yes (both)
r4198393531 module_tags_why a submodule's annotated tag object on a published commit was in no guard or loss plan 77db871 test_a_submodules_annotated_tag_object_is_never_lost_with_its_tree[only-in-the-submodule,on-its-remote-too] only-in-the-submodule yes; on-its-remote-too passes, as the control
r4198392951 walk_estate the dependents walk passes over caches, tool environments and site-packages filed in #176 n/a n/a

Notes on the round:

  • c538ec1 came out of the proof run itself. A submodule repository whose core.worktree is gone (every gone registration's) failed every git read on "cannot chdir", so it was kept as "unreadable" and never actually read. Those reads now run with the repository's own objects/ as the working tree.
  • 499d2ab makes the regression's own final check read that way too.
  • de168db is the manual and --help for the round.
  • The submodule tag-object check asks remotes, so it runs at the act only. The dry run reads remove; --yes keeps the tree.
  • lane-worktrees: the dependents walk passes over caches, tool environments and site-packages (filed from #174 Copilot round 2) #176 is for a person to decide. Walking those subtrees costs about 683,000 directories and 397 s cold on Eagle's estate. Treating them as a partial search would keep every clone. The issue lists the options.

Measurements: a read-only dry run over this lane's real estate on Eagle

These used lane-worktrees sweep openRepoTools-3 --dry-run --porcelain. No --yes on anything real. Every run exits 2 in both builds, because this lane has no #97 snapshot (state NONE) yet 10 trees are its. That refusal is the gate working.

run before (45fa45d) after (c550e89)
gate (--dry-run --porcelain) 15.1 s, rc 2; 5 live, 4 foreign, 5 retire 12.7 s, rc 2; identical rows
--include-foreign --branches --include-scratch --include-caches 13.3 s, rc 2; scratch 6 archive+remove, 1 live; branches 1 keep, 2 list; no cache rows 30.6 s, rc 2; scratch 4 archive+remove, 2 keep, 1 live; branches 1 keep (the two review/* branches are published under their own names, G7); 27 cache rows
--include-sandboxes 17.7 s, rc 2; 83 sandboxes remove 13.3 s, rc 2; 81 list, 4 remove (pytest-of-brett with dead .lock pids)

Notes on the "after" column:

  • dash-scratch and featlist-scratch are now kept, because repositories lie under them (item 7). dash-scratch/bite has no commits and a staged .gitattributes. The table's earlier archive would have swallowed it.
  • The 27 cache rows are the __pycache__ and .pytest_cache directories inside those two kept scratch directories. Before, they rode inside the scratch archive.
  • The second run's extra ~17 s is the whole-estate walk. --include-scratch turns it on.
  • The table's note lists 3 unreadable ssh-agent pids (item 2).
  • The worktree stayed clean through every run (git status --porcelain empty).

Verification

tests/run.sh -k '<selector>' never started within 15 minutes on Eagle; the workstation lock was held by other lanes' suites throughout. CI is the record. I used temporary proof/openRepoTools-3/* branches and dispatched a trimmed tests.yml on Linux. They are deleted now.

  • Head, full Linux suite:

    • run 37450303540 on the earlier ordering: 1195 passed, 2 deselected (macos_job), 31 min;
    • run 37452432577 on c550e89, this PR's head: 1197 passed, 2 deselected, 33 min.
  • Base: this branch's tests ran against 45fa45d's lane-worktrees:

    • run 37449846462: all 36 new sweep cases failed;
    • run 37452162948: G9 and the reworked A1 and G6 cases failed;
    • run 37452793929, with base lanes-edit.sh too: the shell suite failed only on G13's 4 assertions, and test_yes_needs_no_enabling_variable was refused by the switch.
    • Every other --yes case in that run was also refused by the switch. That is why the per-item base proofs above ran before the switch commit, when the fixture still set the variable.
    • In run 37449846462 the controls test_a_sparse_checkouts_absent_files_are_no_edit and ...git_lfs_is_configured[False] passed at base, as designed.
  • Round 1 (lane openRepoTools-1), proof run 37500368196 on a temporary branch, proof/openRepoTools-1/174-round1, deleted once read. It is 685cbe3 with a trimmed workflow, and tests/run.sh on the workstation was starved by other lanes' suites:

    • head (685cbe3): -k lane_worktrees, 101 passed;
    • red-c550e89: the 13 new cases, 13 failed;
    • red-45fa45d, with that base's --yes switch set in the fixture: 12 failed, 1 passed (the on-origin-too tag case, as explained above).
  • Round 2 (lane openRepoTools-1). These used temporary branches proof/openRepoTools-1/174-round2, -round2b and -round2c, each deleted once read:

    • run 37504874509, the first: it showed the core.worktree read failure that c538ec1 fixes;
    • run 37505731437: it showed the test's own read, fixed by 499d2ab;
    • run 37506230403, on 499d2ab:
      • head (-k "lane_worktrees or repo_hygiene or openrepotools_command"): 346 passed and 1 failed. The failure is test_the_macos_job_parses_every_bash_file_this_repository_ships, which reads the trimmed proof workflow itself.
      • red-685cbe3: 10 failed and 1 passed (the on-its-remote-too control).

This PR's own CI (Linux and the ready-gated macOS job) is the landing record. I have not applied ready.

Not in this PR

#179 (PR 2), stacked on this one, takes the honesty, portability and test items and says Closes #170:

  • C1, C4, C5, C6;
  • B8, C2, C3;
  • items 3, 9, 10 and 11;
  • G3, G4, G8, G10, G11, G12, G14 and G15;
  • E1, E2, E4 to E10, and (d).

Items 9 to 11 were not in the brief's list. I am taking them there so that "Closes" is true.

@codex review (it is expected to answer that its quota is spent; that is not a finding).

Part of #170
Refs #162 #168 #169 #163 #176 #179

Lane: openRepoTools-3 (opened), openRepoTools-1 (writer since 2026-10-06T16:20Z)

🤖 Generated with Claude Code

Summary by Sourcery

Harden lane-worktree sweeping against data loss and gate errors, then restore verified --yes operations.

New Features:

  • Re-enable --yes execution for worktree sweeping and archive expiration without an environment opt-in.
  • Add removal safeguards that verify commits, reflogs, tags, bundles, submodules, LFS data, and dependent repositories before destructive actions.
  • Expand sweep discovery and gate validation to fail closed on unreadable state, register fetches, missing inventory data, nested lane claims, and incorrect branch counts.

Bug Fixes:

  • Prevent data loss involving unpublished commits, reflog-only and tag-only objects, submodule repositories, hidden edits, untracked files, Git LFS content, scratch directories, bundles, and open pull-request branches.
  • Prevent remote branch deletion or publication when pull-request status, lane ownership, or register history is ambiguous.
  • Prevent sandbox, cache, scratch, clone, and pruned-registration removal when repositories or unknown liveness may be present.
  • Make pathspec and register-write failures refuse or stop sweeps instead of being treated as clean or allowing subsequent actions.

Enhancements:

  • Add per-removal self-checks and disposition recording, with exit status 2 when a removal could lose data.
  • Improve estate-wide dependent and other-lane claim discovery while preserving conservative behavior for partial scans.
  • Refine archive, rescue, bundle, ignored-file, branch, liveness, and sandbox classification rules.

Documentation:

  • Update lane sweeping documentation and help text to describe the enabled destructive operations, fail-closed guards, self-checks, rescue handling, and revised exit behavior.

Tests:

  • Add comprehensive regression coverage for destructive sweep paths, gate correctness, sandbox provenance, nested repositories, pull-request handling, bundle retention, LFS behavior, and self-check refusals.
  • Update test-run roots and fixtures to provide sandbox provenance markers and workspace register data.

brettheap and others added 30 commits October 5, 2026 20:24
A swap that does not finish gracefully leaves its writers' trees where
they stood, and nothing removed them: a person archived, verified and
removed them by hand. `lane-worktrees sweep <lane>` makes that one act,
dry run by default, under one rule: nothing is deleted that is not
first on origin or in a bundle under the sweeps directory, and a live
writer's tree is never touched.

Which trees are the lane's is #97's inventory and the disk, never the
derived index (Amendment 14(b)); a tree the inventory does not name is
FOREIGN and is left unless --include-foreign and a --word, and one
another lane's inventory names is never taken. Who may act is #97's
reconciliation, read before any write including the fetch: a lane bound
elsewhere, held by another live session, managed-owned or unreadable is
refused (exit 2); a lane this session holds acts only on the writer
count the coordinator states with --live.

"Merged" is the register's LANDED line for the branch's PR or gh's
MERGED for it, never ancestry alone. Unpublished commits are pushed
under the branch's own name, never its upstream's (a branch made from
origin/main tracks main), or to rescue/<lane>/<slice>-<UTC> where
origin diverged; dirty work becomes a WIP commit built through a copy
of the index, so a refused push leaves the tree exactly as it was.
Removal re-proves the head and the content at the moment it happens.

Also --branches, --include-scratch, --include-caches,
--include-sandboxes, --links, alternates and local-remote detection
(LOAD-BEARING clones are never removed), sweep --expire with 90-day
retention that never expires an archive whose rescue left origin, and
the porcelain contract lane-end's gate (#163) reads: 0 nothing to
retire, 3 something to retire, 2 refused.

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tests/test_lane_worktrees.py builds a lane's estate under tmp_path: a
bare origin reached through a url.insteadOf for a GitHub-shaped URL, the
lane's checkout and both worktree roots, and fakes for lanes-edit.sh,
gh and tmux. One lane holds a tree in every state, and the dry run is
held to the table row for row and to a whole-estate snapshot that does
not move. The same lane under --yes is held to every act: pushes,
rescue branches seen on origin, the WIP commit's subject and parent,
the bundles, the ignored-file archive without bytecode, a manifest
that verifies, and one register line per tree.

Also: a refused push leaves the dirty tree byte for byte; a live writer
(a process's cwd, a tmux pane) is never touched; this session's --yes
needs its writer count; bound elsewhere, held elsewhere and unreadable
are refused with 2 and change nothing; merged by register and by gh
against a branch on main by ancestry alone; no gh, nothing merged;
--include-foreign takes a word and never another lane's tree;
--branches; scratch, caches and sandboxes; --links; a LOAD-BEARING
clone; --expire at 89/90/120 days; the porcelain exit codes; and two
cases on the REAL lanes-edit.sh, for the inventory, the register line
and a lane bound on another host.

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`openRepoTools --install` places `lane-worktrees` at the end of
INSTALLABLES, so every index a test takes still names the file it did.
The file count moves 16 -> 17 and the artifact count 30 -> 31 wherever
they are stated, with the count history extended rather than restated;
the receipt sentence now says 31 artifacts, 29 rows. --help names the
new command.

The hygiene suite compiles `lane-worktrees` beside `lanes-index`: the
two shipped commands that are Python and have no .py suffix for the
glob to find.

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Retiring a lane's worktrees": the rule, which trees are the lane's and
who may act, the disposition table as implemented in the order it is
decided, the other rows (--branches, scratch, caches, sandboxes, links,
bundles), the sweeps directory and its manifest, the one register line
per tree, retention and sweep.conf, the porcelain exit contract for
lane-end's gate, and the two protocol lines the act assumes, proposed
for the amendment that ratifies it.

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`os.walk` lstat()s every entry, and the estate on Eagle is tens of
thousands of directories: a read-only `--links` dry run took 245 s.
A scandir walk reads each entry's kind from the directory read itself,
so only a symlink or a `.git` file costs a call of its own. Measured
on the same estate: 16.7 s, with the same 403 broken links found (99
of them worktree gitdir pointers).

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A suite run left tests/__pycache__, .pytest_cache and, when killed, its
sandboxes in whichever worktree ran it; the estate cleanup found those
were 968 of 1,000 removals (#162, causes 3 and 4). tests/run.sh now
sends bytecode to ${XDG_CACHE_HOME:-~/.cache}/openRepoTools/pycache,
turns pytest's cache off, and roots --basetemp and TMPDIR under one run
root in ${XDG_STATE_HOME:-~/.local/state}/openRepoTools/tmp/<UTC>-<pid>/
that an EXIT trap removes however the run ends.

The suite now runs in the background and is waited for: bash runs a trap
only once its foreground child returns, so a TERM used to wait out the
whole suite. The INT and TERM handlers stop the suite, then exit, so the
EXIT trap removes the run root and, on the mkdir path, the lock. The
lock is computed before TMPDIR moves, so it stays the workstation's.
The repository venv is used when it has pytest, first on PATH so the
command line still reads `python3 -m pytest`.

tests/test_run_wrapper.py proves it against a stub suite: the
relocation, the run root gone after a pass, a failure, a TERM and an
INT, under flock and under the mkdir lock, and the venv.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One virtual environment per repository, outside the estate, is the
convention the sweep's --include-caches row assumes (#162, cause 3): a
venv/ inside a worktree is a leftover to remove. --install now names
this repository's, ${XDG_CACHE_HOME:-~/.cache}/openRepoTools/venvs/
openRepoTools, and says whether it is there; when it is not, it prints
the two commands that make it. It never makes one: that needs pip and
the network, and an install from a checkout needs neither.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cause 19 of the cleanup analysis: a handoff's attachments were committed
with a whole virtualenv's __pycache__ inside them, and brett-wip carries
703 bytecode paths in its history. `wip init` now adds __pycache__/,
*.pyc, *.pyo, .pytest_cache/, .mypy_cache/, .ruff_cache/, node_modules/,
.venv/ and venv/ to the seeded .gitignore, in wip_substitute - the one
function both step-7 paths go through - so the bytes a seed writes and
the bytes a re-run compares are the same. A line the template already
carries is not repeated, so a template that adopts them upstream seeds
them once.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The .gitignore lines keep a new workspace clean; an older one without
them still stages whatever a pathspec names. commit_push now asks git
what its pathspec would stage (git add --dry-run, which honours
.gitignore exactly as the real add does) and refuses, exit 2, nothing
staged, when any path has a __pycache__, cache, node_modules, venv or
site-packages component or is *.py[co] - and offers the .gitignore
lines that workspace lacks, with the one command that adds them.

Attachments are committed by hand, so the same question is a
subcommand: `lanes-edit.sh pathspec-check <path>...` answers 0 clean,
2 with the offending paths on stdout and the offer on stderr, 64 usage.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The report is the net under every actor that never runs lane-end, and
it only works if it is read every day. lane-start now runs
`lane-worktrees sweep --all --dry-run --report` once per UTC day per
workstation, after the row is written and before the launch: the first
start of the day takes report-<YYYYMMDD>.stamp under the state
directory with an atomic `set -C` create, removes older stamps, and
starts the report detached, stdin, stdout and stderr closed, writing
reports/<UTC>.md. Every step either works or is skipped in silence, so
the report never delays a start and never fails one. --dry-run starts
none; LANE_WORKTREES_REPORT=off is the switch, and the suite sets it.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Workspace creation outpaces closeout, and nothing counted it. The
report reads every lane's #97 inventory and snapshot (beside each
checkout's parent, where #97 keeps them), the workspace register and
the disk - never the derived index - and lists, as one markdown
document: FOREIGN clones with size, last commit and Lane: owner;
orphaned trees of ENDED lanes; unmerged branches with a missing,
diverged or unpushed upstream; root-main divergence, with Amendment
4's remedy where only handoff/register paths moved; rescue branches
and dirty inventory trees awaiting disposition past aging_days; caches
and sandboxes by size; ignored directories over ignored_report_mb;
evidence-shaped paths; archives past retention (--expire's own table,
now shared as expiry_rows); the workspace's .gitignore and bytecode
history; and `status --all`'s findings, reported as what they are and
never counted as dirt. The head table carries the rescue-branch count
and the sweeps directory's size.

It changes nothing: no fetch, no index refresh, no expiry. --post
writes it to a file or comments it on owner/repo#n through gh.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The lanes manual gains the report (its sections, --post, the daily run
from lane-start and its switch) and what keeps leftovers from arriving:
the run.sh relocation, one venv per repository outside the estate, one
evidence root per repository under the state directory, and the
workspace's bytecode rules with lanes-edit.sh's refusal and
pathspec-check.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every one of these let a destructive act rest on a read that was stale,
failed, or answered a different question:

- Merged now needs the PR's base to be the default branch (origin's HEAD,
  else main/master): a merge into a release branch is not a landing. An
  OPEN PR protects its branch before any older merged PR on that branch
  counts, unless the register LANDED that very PR.
- A failed `rev-list` distance is a read error, never "0 ahead", for a
  tree and for --branches' upstream state alike.
- A clone whose git directory linked worktrees share is kept. Removing it
  would take their repository with it.
- A tree that another lane's inventory also names is kept. An inventory
  record that cannot be read refuses the sweep (exit 2) instead of being
  skipped. The lane name goes through `canon-lane` before any path is
  derived from it, and an unreadable alias table refuses.
- Liveness is asked again before the rescue and again just before
  removal, afresh (processes, tmux, the recording session). A scan that
  cannot be made leaves the tree. Scratch is removed only if no writer
  arrived and nothing outside its caches changed while the tar ran. A
  cache is rechecked against its owning tree.
- Caches never come from a FOREIGN tree, a live one, or one whose
  liveness is unknown.
- Branches are deleted at the SHA that was judged: `update-ref -d <old>`
  locally, a --force-with-lease push for the remote. Each failure is
  counted, and the register claims "+ remote" only for a remote delete
  that happened.
- --branches fetches the lane's own checkout too, and a failed fetch
  deletes nothing.
- A stale registration is removed by `git worktree remove <path>` alone,
  never by a repository-wide prune.
- An ignored-file listing that failed stops the act. It is never read as
  "nothing ignored".
- Each invocation gets its own archive directory, created exclusively.
- An origin-less worktree under --bundle --yes is bundled and removed,
  as its dry run says, instead of being read as a failed fetch.
- --expire requires the rescued SHA itself on origin and an archive that
  is whole: a MANIFEST.sha256 listing every file at its digest, and a
  rescues.tsv whose rows parse.

Ten new or extended cases. Each one fails against the previous head and
passes here.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The manual now states what changed: the canonical lane name, refusal on
an unreadable record, contested trees, the single-registration prune,
the linked-worktree clone guard, merged-into-default with open PRs first,
SHA-fenced branch deletes, liveness asked again before removal, scratch
and cache rechecks, the exclusive archive directory, and expiry of whole
archives only.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The merge from #168 brought in archive_ledger and the exact-SHA check.
The report's archive section reads them through expiry_rows, so its
docstring now says it keeps archives that are not whole and those whose
rescue branch moved.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot on #169: a TERM to the pytest pid alone leaves whatever it is
waiting on (a shell suite, a git, a fixture's sleep) running, while the
EXIT trap deletes its temp root and releases the mkdir lock around it.
The suite now starts in a process group of its own, with `set -m` on for
that one line only. The INT/TERM handlers and the EXIT trap TERM the
whole group, reap the leader, wait up to ten seconds for running members
and then KILL the group. Running members are counted from `ps`, never
`kill -0`, because an orphan's zombie answers `kill -0` until whoever
adopted it reaps it.

The kill cases now give the stub suite a child of its own and require
it to be gone. Against the previous run.sh that case fails with "the
suite's own child outlived its wrapper".

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot on #169: the refusal classifies any path under a site-packages
directory, since that is a virtual environment under any name, but
neither the offered lines nor the seeded ones covered it. Following the
offer therefore left the refusal standing. `site-packages/` now joins
BYTECODE_IGNORES and WIP_HYGIENE_LINES. The shell case adds a venv
named env-x: it is refused, `site-packages/` is among the lines offered,
and it is still refused until that line is added.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot on #169, two report defects:

- Two clones of one origin, neither named for the repository, were both
  treated as canonical, so the duplicate finding vanished. The checkout
  is now chosen deterministically (the one named for the repository,
  else the shallowest), and every other clone is reported, with a note
  when the choice was ambiguous.
- `status --all` was pointed at the estate only when --estate was given.
  It now always gets the root the report resolved, and lane-start passes
  its own $PROJECTS_ROOT to the daily run.

The report's hygiene check wants site-packages/ too. Each new assertion
fails against the previous head.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seven of round 2's eight. Each one let an act proceed on an unknown:

- Another lane's inventory record or directory that cannot be read, or
  that names no path, now refuses the sweep. Who else claims a tree is
  read before the fetch, so the refusal writes nothing.
- A submodule's ignored files (an .env) keep the tree. The tree's
  ignored archive covers the superproject only.
- Ignored files are printed before the archive and listed again at
  removal. A change, an addition or a failed listing leaves the tree.
- A prune candidate whose directory reappeared is left for the next
  sweep to classify.
- A pytest sandbox's .lock is read again at the act, and a live pid
  leaves it.
- `tracked()` returns None when a repository's index cannot be read,
  and such a cache is left.
- A manifest entry whose file is gone makes the archive not whole.

The eighth, unreadable /proc entries of same-account processes, is a
design trade and is filed in #170. On Eagle, 9 such processes exist at
any time (6 `timeout`, 3 `ssh-agent`), so reading them as "unknown"
would keep every tree on every sweep.

Four new or extended cases. Each fails against ae17742 and passes here.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot round 2 on #169: `git add --dry-run` says nothing about a path
the index already holds at those bytes, yet the path-limited commit
still takes it. bytecode_in_pathspec now also reads `git diff --cached
--name-only --diff-filter=d` for the same pathspec. A staged deletion of
bytecode is a cleanup and is let through. The shell case force-stages a
.pyc after the ignore lines are in place: pathspec-check and the commit
path both refuse it, and nothing is committed.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot round 2 on #169: the command is meant to be pasted into a
shell, and $XDG_CACHE_HOME is the person's to choose, spaces included.
Both paths are now `printf %q`-quoted. The new case installs with a
cache directory containing a space, and shlex reads each path back as
one word.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot round 2 on #169: an unreadable lane snapshot or inventory
record became an empty one, so a lane or tree dropped out of every
section and the report could look clean. Reads now go through _ls and
_record. A failure other than "not there", a tree record naming no
path, or an inventory tree whose git status fails is a row in a new
section, "Records the report could not read". The unused _sidecar
reader is gone.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test_repo_hygiene caps AGENTS.md at 316 lines and README.md at 486, and
both files were exactly at their caps on main. The run.sh paragraph had
taken AGENTS.md to 330 and the venv sentence README.md to 491. The full
text already lives in docs/README-lanes.md, so AGENTS.md now says the
same thing in the three lines the old sentence took, and README.md says
it on the line it extends. No cap is raised.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot on #169: macOS has no flock, so the wrapper takes the mkdir lock
and never creates openrepotools-pytest.lock. The relocation case checked
for that file unconditionally and would have failed the macOS job, which
is the landing gate. Where there is no flock, the case now checks
instead that the mkdir lock was released.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The adversarial review of 3c0b188, reproduced with its probes against
the suite's own Estate fixture:

1. --yes and --expire --yes are refused, exit 2, changing nothing,
   unless LANE_WORKTREES_ENABLE_YES=1 is set. #170 lists data-loss paths
   that are still open. The suite's Estate sets the variable; a real
   estate should not until #170 lands. The dry run, --porcelain and
   --report are untouched.
2. B1: a tree that #97's inventory does not name is still the lane's if
   it stands under .lane-worktrees/<lane>/, or if it sits in
   <checkout>/.claude/worktrees on a branch whose own commits carry this
   lane's Lane: trailer. Another lane's claim still wins. Such trees keep
   the quiet-hours liveness rule. A lane whose state is NONE but which
   has trees of its own is refused, exit 2. On Eagle, `sweep
   openRepoTools-3 --dry-run --porcelain` gave exit 0, `summary 0 15 0
   15`. It now gives exit 2, `summary 9 13 4 4`: 9 trees are the lane's,
   and the snapshot is NONE.
3. B2: subprocess output and git metadata files are decoded with
   surrogateescape, and stdout and stderr write with it too, so a
   Latin-1 worktree name is reported rather than raising. An unreadable
   pytest-of-$USER is a kept sandbox row. A catch-all keeps errors that
   no read caught inside the contract: exit 2 on a dry run, and under
   --yes DISPOSITION.md is written first and the exit is 1.
4. B3: every porcelain field escapes backslash, TAB, newline and CR.
   Worktree registrations are read with `git worktree list -z` where git
   has it, so a newline in a path no longer becomes a phantom
   registration.

Five new cases (switch off, ownership plus NONE, escaping, non-UTF-8
path, unreadable sandbox root), each failing against 3c0b188. Two cases
were moved off the lane root to keep testing FOREIGN trees. 40 cases.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The manual now leads the sweep section with the --yes switch and #170.
It also says which unrecorded trees are a lane's and that a NONE snapshot
with trees is refused. The exit contract now covers the escaped fields
and the catch-all.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adversarial review B7: `set -m` puts the suite in a background process
group with the terminal as its stdin. Anything that reads the terminal
(--pdb, breakpoint(), input()) is then stopped by SIGTTIN, and the
wrapper waits on it for ever while holding the workstation flock. With
a terminal on stdin the suite now runs in the foreground, in the
terminal's own process group, and a Ctrl-C reaches all of it from the
terminal. Without a terminal the process-group stop is unchanged.

The new case runs the wrapper on a pseudo-terminal whose stub suite
prints a prompt and reads a line. It passes here. Against the previous
run.sh it hangs and fails, with the transcript showing the prompt and
the typed line that was never read.

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

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

brettheap added a commit that referenced this pull request Oct 6, 2026
…h still points at the start commit it resolved before git ran, so a commit a post-checkout hook makes on it is kept reachable (exit 1, the branch named), and lane-end's gate gives an unreadable lane root exit 1 whatever the sweep answered, not only on 0 (Copilot round 2 on #175).

Two new shell-suite cases, each red at 41b5a6a: a hook that commits and then
fails, and an unlistable lane root beside an unpublished branch of the lane's
own (the sweep answers 3). The third round-2 thread, whether a lane-owned
branch with a fully published open PR should hold the gate, is a question
about #168's porcelain contract that #174 is changing, filed as #178.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap
brettheap changed the base branch from feat/lane-worktrees-report to main October 6, 2026 20:43
…esidue

Main is 500687c, the squash of #168 and #169, so this branch's history
and main's carry the same content under different commits. The merge is
taken against this branch's old base 45fa45d, so main's text stands
wherever #174 did not itself change it; the result differs from main by
exactly #174's own diff.

Lane: openRepoTools-1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap and others added 3 commits October 6, 2026 22:33
…s lane's (#174 review R1)

A tree in `<checkout>/.claude/worktrees` that no inventory names was
adopted when ANY commit of it not on origin's default branch carried
this lane's `Lane:` trailer. A tree another lane stacked on this lane's
commit - base by repoA-1, tip and an edit in progress by repoB-2 - was
therefore repoA-1's: its `--yes` removed the tree and pushed repoB-2's
edit to rescue/repoA-1/..., and on the real estate
`.claude/worktrees/closeout-2` was adopted by both lanes sharing the
checkout.

`_lane_claims` now also asks `head_lane_trailer`: HEAD's own last
`Lane:` trailer, read as `read_tree` reads `lane_trailer` (which runs
after the claims are decided), must be this lane's. The tree is then
the lane's whose commit HEAD is, and FOREIGN to every other.

The new test builds the reviewer's probe: repoA-1's dry run reads the
tree FOREIGN (exit 0), its `--yes` leaves the tree and its edit and
pushes nothing, and repoB-2's dry run still adopts it by its trailer.
The manual's adoption sentence and the docstring say HEAD's own trailer.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…repository (#174 review R2)

With `--include-sandboxes --yes` an unmarked `/tmp/tmp.*` untouched for
aging_days (14) was removed with no archive. The reviewer's probe: one
tmp.* holding a BARE repository with the only copy of a commit, one
holding notes.txt - both removed, nothing archived. `repository_within`
looked for a `.git` alone, which a bare repository has not, and
tests/run.sh writes its marker in its own run roots, never in a tmp.*,
so the 14-day rule was the only path that removed real ones.

#170 item 1, option (b), as the issue words it: list `tmp.*` and never
remove it, so only `pytest-of-$USER/pytest-*` with a dead `.lock` pid
and tests/run.sh's own `<UTC>-<pid>` run roots are removed.
`sandbox_items` now lists every tmp.* older than
sandbox_min_age_minutes - with its mark where it has one and any
repository in it - and `_act_item` leaves one should it ever reach the
act. A mark in a tmp.* is a file anybody may have left (a `.lock`
holding digits), so it no longer makes one removable either.

`repository_within` also finds a BARE repository (a `HEAD` beside an
`objects/`, as `module_repos` reads one): the path itself, or one
anywhere inside it (`nested_repos(..., bare=True)`), so a cache holding
one is kept too.

Tests: the reviewer's probe (both tmp.* listed and left, the commit
still in the bare repository, and `repository_within` naming a bare
repository nested or at the top, and nothing in a notes-only one); the
two cases that held a marked or 14-day-old tmp.* to removal now hold it
to `list`. The docstring and the manual's `--include-sandboxes` row say
what `--yes` now does to a tmp.*.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n unreadable one is a note (#174 review R3)

#170 G6 made `_other_lanes` read every lane's control root the
register names, in ANY estate on the workstation, and a claim that
could not be read refused. One pathless or unreadable sidecar anywhere
therefore made the sweep exit 2 for a lane with nothing on disk (the
reviewer's probe: rc=2 at 499d2ab, rc=0 at 45fa45d) - and once #175
gates lane-end on it, every lane-end but `--inventory-only` would be
blocked by it.

Two changes, self-contained in lane-worktrees so #175 takes them when it
merges main after #174 lands:

- a lane's recorded `dir` from the register is read only where its
  `.lane-state` lies under this sweep's estate root (a lane recorded in
  another estate keeps its claims there); this lane's own control root,
  the override and `$PROJECTS_ROOT` are read as before;
- `unreadable()` is a NOTE, not a refusal, when the sweep has no tree:
  a claim only takes a tree away from this lane, so with none it can
  lower nothing. With a tree it still refuses, as before.

The new test is the probe in two places: a pathless sidecar of a lane
recorded in another estate (not read: the gate exits 0, then 3 once the
lane has a tree), and one in this estate (exit 0 with the note, then 2
once the lane has a tree). #170 G6's register case put its other lane
outside the estate root; it now stands in a plain directory of a
repository inside the estate, which only the register's record reaches.
The docstring, the manual's claims paragraph and its exit contract say
so.

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

sonarqubecloud Bot commented Oct 6, 2026

Copy link
Copy Markdown

brettheap added a commit that referenced this pull request Oct 6, 2026
…lane-worktrees-residue-tests (#179)

One conflict, in tests/test_lane_worktrees.py,
test_killed_suite_sandboxes_whose_owner_is_gone_are_removed: #179 swapped
the literal pid 999999 for _dead_pid() (#170 C6), and #174's R2 changed what
the test expects of a `tmp.*` (a suite's `.lock` in it is no proof; it is
listed and kept). Resolved with R2's comment and expectation and #179's
_dead_pid(): `tmp.deadbeef01` with a dead-pid pytest `.lock` is listed and
kept, while `pytest-of-$USER/pytest-*` with a dead `.lock` pid is still
removed.

Lane: openRepoTools-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 6, 2026
…mp.* listing case

After merging #174's review fixes (R2), test_a_tmp_dir_is_listed_and_never_removed_marked_or_old
asserted the literal text "a pytest .lock (pid 999999, gone)". #179 writes the
`.lock` with _dead_pid() (a pid proved dead, never a constant a host with a
large pid_max may be running), so the row names that pid and the literal
failed: the one post-merge failure, 1 failed and 132 passed.

The test keeps the pid it wrote in dead_pid and asserts the row names it.
The sweep's behaviour and wording are unchanged.

Lane: openRepoTools-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 7, 2026
…tched-off --yes text, add --push-untracked to add's refused list

Merged ahead of #174's squash, so #175's macOS gate runs beside #174's. A
simulated squash (4ea84c8's tree on 500687c) merges into this head with no
conflict, and the result is this head's own tree.

The one conflict was the lane-worktrees docstring. #174 deletes main's
"--yes is SWITCHED OFF until #170" paragraph, which #175 had kept, and #175
adds its ADD paragraph in the same place. The resolution keeps #174's
deletion. Two of #175's paragraphs move, with their words unchanged, so that
neither sits against a #174 edit, which would make the post-squash merge
conflict:
  lane-worktrees:95  ADD (`add <lane> <slice>`, #163) - now after the
                     PORCELAIN paragraph and before "Exit codes:"
  docs/README-lanes.md:3838  "`lane-end` reads it with ..." - now before
                     "Every field is escaped", in the same section

Text in #175's own additions that #174 made false (--yes is live again, and
removes only what is on origin or in a bundle, never a live writer's tree):
  lane-end:128-131   --sweep usage: no longer "switched off until #170"
  lane-end:619-621   the --sweep comment: the same
  lane-end:625       the --sweep refusal: names `lane-worktrees sweep <lane>
                     --yes` as the act, run first on its own
  lane-end:1906-1907 the gate's header comment: the same
  lane-end:2133      the gate's refusal: "the one act that retires them (--yes
                     removes only ...)" instead of "once #170 lands"
  docs/README-lanes.md:529-530, 551-554  the same two sentences
  tests/test_lane_helpers.sh:15821-15822, 15832  the cases that named #170 now
                     hold the true text

add's refused list:
  lane-worktrees:5540  --push-untracked, #174's sweep flag, is usage for add
  tests/test_lane_helpers.sh:15747-15749  a case for it

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 7, 2026
…s, as it has no branches (#174 + #175 integration)

With #174 and #175 together, `lane-end repoQ-1 --retire --force` exited 2 where
it had exited 0. Each PR alone was green. repoQ-1's recorded directory,
$HOME/projects/repoB, is a plain directory on purpose. #175's close-out gate asks
the sweep about it, and #174's discovery now refuses a `git worktree list`
that fails (#170 G5): "the worktree registrations of .../repoB could not be
read". Before #174, a failed list read as no registrations.

#175 had already ruled on this directory for branches: in `_branch_checkouts`,
a recorded directory that git says is NO repository is an absence, not a read
that failed (`no_repository()`). `_discover` now uses the same guard, with the
same expression, so a directory that is no repository is not listed for
registrations. Any other failed list, in a repository, still refuses under
G5.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 7, 2026
…git cannot read is #170 G5's refusal (#175, lane 2's finding)

The guard 49bfa40 added read `no_repository()` at its word, and git answers "not a
git repository" for a repository it cannot use as well as for no repository at
all. Lane 2 found the hole in its adversarial pass, and lane 3's reviewer
found it independently. A lane checkout whose `.git` is mode 000, a `.git`
file whose gitdir is gone, a repository with HEAD or objects/ removed, or an
orphaned linked worktree read as an ABSENCE. So `lane-end --retire --force`
passed the close-out gate with `summary 0 0 0 0` over a branch that was never
published, which is exactly what #170 G5 forbids. The same gap was in #175's
own `_branch_checkouts` guard.

The rule: a recorded directory with no .git entry of its own is an absence;
anything else that fails is G5's refusal. It now sits in `no_repository()`
itself, so it covers both callers, `_discover` and `_branch_checkouts` (the
only two).

Tests:
  tests/test_lane_worktrees.py (lane 2's patch, placed before
    test_usage_errors_are_64, clear of #174's hunks, so that the merge after
    #174's squash stays clean): the sweep refuses, exit 2,
    naming the registrations it could not read, for HEAD removed, a .git file
    pointing at a missing gitdir, objects/ removed, and .git mode 000; a
    directory with no .git at all is never that refusal.
  tests/test_lane_helpers.sh, #163 section: a mode-000 .git holding an
    unpublished branch with the lane's trailer gives a sweep exit of 2 and
    `lane-end --dry-run` exit 2, with the reason relayed; a .git file pointing
    nowhere beside notes.txt gives 2; a plain directory with no .git gives
    the sweep 0.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap
brettheap merged commit 5eb3d5f into main Oct 7, 2026
8 checks passed
@brettheap
brettheap deleted the feat/lane-worktrees-residue branch October 7, 2026 01:19
brettheap added a commit that referenced this pull request Oct 7, 2026
…s, three-way against #174's final head 4ea84c8

This branch is stacked on #174 and already carried its commits up to 4ea84c8.
A plain merge of origin/main would take 500687c as the merge base and see #174's
content added on both sides. The merge is built with 4ea84c8 as the base
instead, so main's side adds nothing of #174's and the result is #179's own
changes over main.

main at merge: 5eb3d5f

Lane: openRepoTools-1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 7, 2026
…s honesty, portability and test items (#170, part 2) (#179)

* Add lane-worktrees: the sweep that retires a lane's abandoned worktrees

A swap that does not finish gracefully leaves its writers' trees where
they stood, and nothing removed them: a person archived, verified and
removed them by hand. `lane-worktrees sweep <lane>` makes that one act,
dry run by default, under one rule: nothing is deleted that is not
first on origin or in a bundle under the sweeps directory, and a live
writer's tree is never touched.

Which trees are the lane's is #97's inventory and the disk, never the
derived index (Amendment 14(b)); a tree the inventory does not name is
FOREIGN and is left unless --include-foreign and a --word, and one
another lane's inventory names is never taken. Who may act is #97's
reconciliation, read before any write including the fetch: a lane bound
elsewhere, held by another live session, managed-owned or unreadable is
refused (exit 2); a lane this session holds acts only on the writer
count the coordinator states with --live.

"Merged" is the register's LANDED line for the branch's PR or gh's
MERGED for it, never ancestry alone. Unpublished commits are pushed
under the branch's own name, never its upstream's (a branch made from
origin/main tracks main), or to rescue/<lane>/<slice>-<UTC> where
origin diverged; dirty work becomes a WIP commit built through a copy
of the index, so a refused push leaves the tree exactly as it was.
Removal re-proves the head and the content at the moment it happens.

Also --branches, --include-scratch, --include-caches,
--include-sandboxes, --links, alternates and local-remote detection
(LOAD-BEARING clones are never removed), sweep --expire with 90-day
retention that never expires an archive whose rescue left origin, and
the porcelain contract lane-end's gate (#163) reads: 0 nothing to
retire, 3 something to retire, 2 refused.

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test the sweep against every row of #162's disposition table

tests/test_lane_worktrees.py builds a lane's estate under tmp_path: a
bare origin reached through a url.insteadOf for a GitHub-shaped URL, the
lane's checkout and both worktree roots, and fakes for lanes-edit.sh,
gh and tmux. One lane holds a tree in every state, and the dry run is
held to the table row for row and to a whole-estate snapshot that does
not move. The same lane under --yes is held to every act: pushes,
rescue branches seen on origin, the WIP commit's subject and parent,
the bundles, the ignored-file archive without bytecode, a manifest
that verifies, and one register line per tree.

Also: a refused push leaves the dirty tree byte for byte; a live writer
(a process's cwd, a tmux pane) is never touched; this session's --yes
needs its writer count; bound elsewhere, held elsewhere and unreadable
are refused with 2 and change nothing; merged by register and by gh
against a branch on main by ancestry alone; no gh, nothing merged;
--include-foreign takes a word and never another lane's tree;
--branches; scratch, caches and sandboxes; --links; a LOAD-BEARING
clone; --expire at 89/90/120 days; the porcelain exit codes; and two
cases on the REAL lanes-edit.sh, for the inventory, the register line
and a lane bound on another host.

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Place lane-worktrees as the seventeenth installed file

`openRepoTools --install` places `lane-worktrees` at the end of
INSTALLABLES, so every index a test takes still names the file it did.
The file count moves 16 -> 17 and the artifact count 30 -> 31 wherever
they are stated, with the count history extended rather than restated;
the receipt sentence now says 31 artifacts, 29 rows. --help names the
new command.

The hygiene suite compiles `lane-worktrees` beside `lanes-index`: the
two shipped commands that are Python and have no .py suffix for the
glob to find.

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document the sweep in the lanes manual

"Retiring a lane's worktrees": the rule, which trees are the lane's and
who may act, the disposition table as implemented in the order it is
decided, the other rows (--branches, scratch, caches, sandboxes, links,
bundles), the sweeps directory and its manifest, the one register line
per tree, retention and sweep.conf, the porcelain exit contract for
lane-end's gate, and the two protocol lines the act assumes, proposed
for the amendment that ratifies it.

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Walk the estate for --links with scandir

`os.walk` lstat()s every entry, and the estate on Eagle is tens of
thousands of directories: a read-only `--links` dry run took 245 s.
A scandir walk reads each entry's kind from the directory read itself,
so only a symlink or a `.git` file costs a call of its own. Measured
on the same estate: 16.7 s, with the same 403 broken links found (99
of them worktree gitdir pointers).

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a test run's bytecode, cache and temp out of the worktree

A suite run left tests/__pycache__, .pytest_cache and, when killed, its
sandboxes in whichever worktree ran it; the estate cleanup found those
were 968 of 1,000 removals (#162, causes 3 and 4). tests/run.sh now
sends bytecode to ${XDG_CACHE_HOME:-~/.cache}/openRepoTools/pycache,
turns pytest's cache off, and roots --basetemp and TMPDIR under one run
root in ${XDG_STATE_HOME:-~/.local/state}/openRepoTools/tmp/<UTC>-<pid>/
that an EXIT trap removes however the run ends.

The suite now runs in the background and is waited for: bash runs a trap
only once its foreground child returns, so a TERM used to wait out the
whole suite. The INT and TERM handlers stop the suite, then exit, so the
EXIT trap removes the run root and, on the mkdir path, the lock. The
lock is computed before TMPDIR moves, so it stays the workstation's.
The repository venv is used when it has pytest, first on PATH so the
command line still reads `python3 -m pytest`.

tests/test_run_wrapper.py proves it against a stub suite: the
relocation, the run root gone after a pass, a failure, a TERM and an
INT, under flock and under the mkdir lock, and the venv.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Name the repository's virtual environment on --install

One virtual environment per repository, outside the estate, is the
convention the sweep's --include-caches row assumes (#162, cause 3): a
venv/ inside a worktree is a leftover to remove. --install now names
this repository's, ${XDG_CACHE_HOME:-~/.cache}/openRepoTools/venvs/
openRepoTools, and says whether it is there; when it is not, it prints
the two commands that make it. It never makes one: that needs pip and
the network, and an install from a checkout needs neither.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Seed the workspace .gitignore with bytecode and cache rules

Cause 19 of the cleanup analysis: a handoff's attachments were committed
with a whole virtualenv's __pycache__ inside them, and brett-wip carries
703 bytecode paths in its history. `wip init` now adds __pycache__/,
*.pyc, *.pyo, .pytest_cache/, .mypy_cache/, .ruff_cache/, node_modules/,
.venv/ and venv/ to the seeded .gitignore, in wip_substitute - the one
function both step-7 paths go through - so the bytes a seed writes and
the bytes a re-run compares are the same. A line the template already
carries is not repeated, so a template that adopts them upstream seeds
them once.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse a workspace commit whose pathspec carries bytecode

The .gitignore lines keep a new workspace clean; an older one without
them still stages whatever a pathspec names. commit_push now asks git
what its pathspec would stage (git add --dry-run, which honours
.gitignore exactly as the real add does) and refuses, exit 2, nothing
staged, when any path has a __pycache__, cache, node_modules, venv or
site-packages component or is *.py[co] - and offers the .gitignore
lines that workspace lacks, with the one command that adds them.

Attachments are committed by hand, so the same question is a
subcommand: `lanes-edit.sh pathspec-check <path>...` answers 0 clean,
2 with the offending paths on stdout and the offer on stderr, 64 usage.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Run the estate report once a day from lane-start

The report is the net under every actor that never runs lane-end, and
it only works if it is read every day. lane-start now runs
`lane-worktrees sweep --all --dry-run --report` once per UTC day per
workstation, after the row is written and before the launch: the first
start of the day takes report-<YYYYMMDD>.stamp under the state
directory with an atomic `set -C` create, removes older stamps, and
starts the report detached, stdin, stdout and stderr closed, writing
reports/<UTC>.md. Every step either works or is skipped in silence, so
the report never delays a start and never fails one. --dry-run starts
none; LANE_WORKTREES_REPORT=off is the switch, and the suite sets it.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Report the estate's leftovers with sweep --all --dry-run --report

Workspace creation outpaces closeout, and nothing counted it. The
report reads every lane's #97 inventory and snapshot (beside each
checkout's parent, where #97 keeps them), the workspace register and
the disk - never the derived index - and lists, as one markdown
document: FOREIGN clones with size, last commit and Lane: owner;
orphaned trees of ENDED lanes; unmerged branches with a missing,
diverged or unpushed upstream; root-main divergence, with Amendment
4's remedy where only handoff/register paths moved; rescue branches
and dirty inventory trees awaiting disposition past aging_days; caches
and sandboxes by size; ignored directories over ignored_report_mb;
evidence-shaped paths; archives past retention (--expire's own table,
now shared as expiry_rows); the workspace's .gitignore and bytecode
history; and `status --all`'s findings, reported as what they are and
never counted as dirt. The head table carries the rescue-branch count
and the sweeps directory's size.

It changes nothing: no fetch, no index refresh, no expiry. --post
writes it to a file or comments it on owner/repo#n through gh.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document the estate report and prevention at creation

The lanes manual gains the report (its sections, --post, the daily run
from lane-start and its switch) and what keeps leftovers from arriving:
the run.sh relocation, one venv per repository outside the estate, one
evidence root per repository under the state directory, and the
workspace's bytecode rules with lanes-edit.sh's refusal and
pathspec-check.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Take Copilot round 1's defects in the sweep

Every one of these let a destructive act rest on a read that was stale,
failed, or answered a different question:

- Merged now needs the PR's base to be the default branch (origin's HEAD,
  else main/master): a merge into a release branch is not a landing. An
  OPEN PR protects its branch before any older merged PR on that branch
  counts, unless the register LANDED that very PR.
- A failed `rev-list` distance is a read error, never "0 ahead", for a
  tree and for --branches' upstream state alike.
- A clone whose git directory linked worktrees share is kept. Removing it
  would take their repository with it.
- A tree that another lane's inventory also names is kept. An inventory
  record that cannot be read refuses the sweep (exit 2) instead of being
  skipped. The lane name goes through `canon-lane` before any path is
  derived from it, and an unreadable alias table refuses.
- Liveness is asked again before the rescue and again just before
  removal, afresh (processes, tmux, the recording session). A scan that
  cannot be made leaves the tree. Scratch is removed only if no writer
  arrived and nothing outside its caches changed while the tar ran. A
  cache is rechecked against its owning tree.
- Caches never come from a FOREIGN tree, a live one, or one whose
  liveness is unknown.
- Branches are deleted at the SHA that was judged: `update-ref -d <old>`
  locally, a --force-with-lease push for the remote. Each failure is
  counted, and the register claims "+ remote" only for a remote delete
  that happened.
- --branches fetches the lane's own checkout too, and a failed fetch
  deletes nothing.
- A stale registration is removed by `git worktree remove <path>` alone,
  never by a repository-wide prune.
- An ignored-file listing that failed stops the act. It is never read as
  "nothing ignored".
- Each invocation gets its own archive directory, created exclusively.
- An origin-less worktree under --bundle --yes is bundled and removed,
  as its dry run says, instead of being read as a failed fetch.
- --expire requires the rescued SHA itself on origin and an archive that
  is whole: a MANIFEST.sha256 listing every file at its digest, and a
  rescues.tsv whose rows parse.

Ten new or extended cases. Each one fails against the previous head and
passes here.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document round 1's rules in the lanes manual

The manual now states what changed: the canonical lane name, refusal on
an unreadable record, contested trees, the single-registration prune,
the linked-worktree clone guard, merged-into-default with open PRs first,
SHA-fenced branch deletes, liveness asked again before removal, scratch
and cache rechecks, the exclusive archive directory, and expiry of whole
archives only.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Say what expiry_rows keeps now that #168 checks whole archives

The merge from #168 brought in archive_ledger and the exact-SHA check.
The report's archive section reads them through expiry_rows, so its
docstring now says it keeps archives that are not whole and those whose
rescue branch moved.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Stop the suite's whole process group when a run ends

Copilot on #169: a TERM to the pytest pid alone leaves whatever it is
waiting on (a shell suite, a git, a fixture's sleep) running, while the
EXIT trap deletes its temp root and releases the mkdir lock around it.
The suite now starts in a process group of its own, with `set -m` on for
that one line only. The INT/TERM handlers and the EXIT trap TERM the
whole group, reap the leader, wait up to ten seconds for running members
and then KILL the group. Running members are counted from `ps`, never
`kill -0`, because an orphan's zombie answers `kill -0` until whoever
adopted it reaps it.

The kill cases now give the stub suite a child of its own and require
it to be gone. Against the previous run.sh that case fails with "the
suite's own child outlived its wrapper".

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Offer and seed site-packages/ with the other bytecode rules

Copilot on #169: the refusal classifies any path under a site-packages
directory, since that is a virtual environment under any name, but
neither the offered lines nor the seeded ones covered it. Following the
offer therefore left the refusal standing. `site-packages/` now joins
BYTECODE_IGNORES and WIP_HYGIENE_LINES. The shell case adds a venv
named env-x: it is refused, `site-packages/` is among the lines offered,
and it is still refused until that line is added.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Report duplicate clones with no named checkout, over one estate

Copilot on #169, two report defects:

- Two clones of one origin, neither named for the repository, were both
  treated as canonical, so the duplicate finding vanished. The checkout
  is now chosen deterministically (the one named for the repository,
  else the shallowest), and every other clone is reported, with a note
  when the choice was ambiguous.
- `status --all` was pointed at the estate only when --estate was given.
  It now always gets the root the report resolved, and lane-start passes
  its own $PROJECTS_ROOT to the daily run.

The report's hygiene check wants site-packages/ too. Each new assertion
fails against the previous head.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Take Copilot round 2's defects in the sweep

Seven of round 2's eight. Each one let an act proceed on an unknown:

- Another lane's inventory record or directory that cannot be read, or
  that names no path, now refuses the sweep. Who else claims a tree is
  read before the fetch, so the refusal writes nothing.
- A submodule's ignored files (an .env) keep the tree. The tree's
  ignored archive covers the superproject only.
- Ignored files are printed before the archive and listed again at
  removal. A change, an addition or a failed listing leaves the tree.
- A prune candidate whose directory reappeared is left for the next
  sweep to classify.
- A pytest sandbox's .lock is read again at the act, and a live pid
  leaves it.
- `tracked()` returns None when a repository's index cannot be read,
  and such a cache is left.
- A manifest entry whose file is gone makes the archive not whole.

The eighth, unreadable /proc entries of same-account processes, is a
design trade and is filed in #170. On Eagle, 9 such processes exist at
any time (6 `timeout`, 3 `ssh-agent`), so reading them as "unknown"
would keep every tree on every sweep.

Four new or extended cases. Each fails against ae17742 and passes here.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse bytecode that is already staged, not only what add would stage

Copilot round 2 on #169: `git add --dry-run` says nothing about a path
the index already holds at those bytes, yet the path-limited commit
still takes it. bytecode_in_pathspec now also reads `git diff --cached
--name-only --diff-filter=d` for the same pathspec. A staged deletion of
bytecode is a cleanup and is let through. The shell case force-stages a
.pyc after the ignore lines are in place: pathspec-check and the commit
path both refuse it, and nothing is committed.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Quote the venv paths in the command --install prints

Copilot round 2 on #169: the command is meant to be pasted into a
shell, and $XDG_CACHE_HOME is the person's to choose, spaces included.
Both paths are now `printf %q`-quoted. The new case installs with a
cache directory containing a space, and shlex reads each path back as
one word.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Report what the report could not read

Copilot round 2 on #169: an unreadable lane snapshot or inventory
record became an empty one, so a lane or tree dropped out of every
section and the report could look clean. Reads now go through _ls and
_record. A failure other than "not there", a tree record naming no
path, or an inventory tree whose git status fails is a row in a new
section, "Records the report could not read". The unused _sidecar
reader is gone.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep AGENTS.md and README.md within their line caps

test_repo_hygiene caps AGENTS.md at 316 lines and README.md at 486, and
both files were exactly at their caps on main. The run.sh paragraph had
taken AGENTS.md to 330 and the venv sentence README.md to 491. The full
text already lives in docs/README-lanes.md, so AGENTS.md now says the
same thing in the three lines the old sentence took, and README.md says
it on the line it extends. No cap is raised.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Assert the flock file only where there is flock

Copilot on #169: macOS has no flock, so the wrapper takes the mkdir lock
and never creates openrepotools-pytest.lock. The relocation case checked
for that file unconditionally and would have failed the macOS job, which
is the landing gate. Where there is no flock, the case now checks
instead that the mkdir lock was released.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Switch --yes off until #170; keep the gate and exit contract honest

The adversarial review of 3c0b188, reproduced with its probes against
the suite's own Estate fixture:

1. --yes and --expire --yes are refused, exit 2, changing nothing,
   unless LANE_WORKTREES_ENABLE_YES=1 is set. #170 lists data-loss paths
   that are still open. The suite's Estate sets the variable; a real
   estate should not until #170 lands. The dry run, --porcelain and
   --report are untouched.
2. B1: a tree that #97's inventory does not name is still the lane's if
   it stands under .lane-worktrees/<lane>/, or if it sits in
   <checkout>/.claude/worktrees on a branch whose own commits carry this
   lane's Lane: trailer. Another lane's claim still wins. Such trees keep
   the quiet-hours liveness rule. A lane whose state is NONE but which
   has trees of its own is refused, exit 2. On Eagle, `sweep
   openRepoTools-3 --dry-run --porcelain` gave exit 0, `summary 0 15 0
   15`. It now gives exit 2, `summary 9 13 4 4`: 9 trees are the lane's,
   and the snapshot is NONE.
3. B2: subprocess output and git metadata files are decoded with
   surrogateescape, and stdout and stderr write with it too, so a
   Latin-1 worktree name is reported rather than raising. An unreadable
   pytest-of-$USER is a kept sandbox row. A catch-all keeps errors that
   no read caught inside the contract: exit 2 on a dry run, and under
   --yes DISPOSITION.md is written first and the exit is 1.
4. B3: every porcelain field escapes backslash, TAB, newline and CR.
   Worktree registrations are read with `git worktree list -z` where git
   has it, so a newline in a path no longer becomes a phantom
   registration.

Five new cases (switch off, ownership plus NONE, escaping, non-UTF-8
path, unreadable sandbox root), each failing against 3c0b188. Two cases
were moved off the lane root to keep testing FOREIGN trees. 40 cases.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document the --yes switch, lane-root ownership and porcelain escaping

The manual now leads the sweep section with the --yes switch and #170.
It also says which unrecorded trees are a lane's and that a NONE snapshot
with trees is refused. The exit contract now covers the escaped fields
and the catch-all.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Run the suite in the foreground when stdin is a terminal

Adversarial review B7: `set -m` puts the suite in a background process
group with the terminal as its stdin. Anything that reads the terminal
(--pdb, breakpoint(), input()) is then stopped by SIGTTIN, and the
wrapper waits on it for ever while holding the workstation flock. With
a terminal on stdin the suite now runs in the foreground, in the
terminal's own process group, and a Ctrl-C reaches all of it from the
terminal. Without a terminal the process-group stop is unchanged.

The new case runs the wrapper on a pseudo-terminal whose stub suite
prints a prompt and reads a line. It passes here. Against the previous
run.sh it hangs and fails, with the transcript showing the prompt and
the typed line that was never read.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Never adopt a standalone clone under the lane's root

5f4bd84 made every unrecorded tree under .lane-worktrees/<lane>/ the
lane's. That included standalone clones, which #162's first protocol
line and the manual keep FOREIGN ("a lane creates worktrees, never
clones"). Copilot flagged it on 296e2f0. Clones are now excluded from
both adoption paths. The ownership case adds a clone under the lane
root and requires it to stay foreign and not retire-counted. On Eagle
none of the lane's seven root trees is a clone, so the dry run is
unchanged there.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fetch the register directly under --yes and refuse when it fails

#170 A1. lanes-edit.sh's log_sync answers 0 on every way it can fail to
fetch, and an inherited LANES_NO_FETCH=1 skips the fetch outright. A lane
rebound on another host after this workstation's last register fetch
therefore read as bound here, and --yes removed its trees (Amendment
18(b): from outside the binding a lane is UNKNOWN, never dead).

Under --yes the sweep now fetches the workspace repository's
origin/<branch> itself, with an explicit refspec, before anything else.
A fetch that fails refuses the run, exit 2, with nothing changed. Every
later helper call reads the ref that fetch left, so an inherited
LANES_NO_FETCH no longer decides. The dry run is unchanged.

The test estate now carries a workspace repository (the fetch's target,
and #170 G6's register) and records its inventory under an earlier
session, as a swept lane's trees are. The new case rebinds the lane on
another host after the last fetch, then runs --yes with origin
unreachable and with LANES_NO_FETCH inherited: both refuse and the tree
stays.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Pin the sweep's fetch refspec so --prune cannot delete local branches

#170 item 6 (Copilot round 3 on #168). With a mirror-style
remote.origin.fetch such as +refs/heads/*:refs/heads/*, the preflight
`git fetch --prune origin` deleted every local branch origin lacks,
before anything was classified or rescued.

The fetch now names its refspec and refmap, as `status --fetch` does:
+refs/heads/*:refs/remotes/origin/*, with --refmap set to the same, so
the configured refspec maps nothing. fetch.pruneTags and the remote's
pruneTags are forced off, so a local tag is never pruned with it.

The case configures the mirror refspec with nothing checked out that
origin has (git refuses to fetch into a checked-out branch, which hides
the prune) and a local-only branch and tag. Both survive --yes.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse when a lane root or a registration list cannot be read

#170 G1 and G5 (Copilot after the cap). Discovery read a lane worktree
root that could not be listed as an absent root, and a failed
`git worktree list` as a repository with no worktrees. With an empty
inventory either one let the dry run exit 0 and clear #163's gate over
trees nobody could see.

Only a root that does not exist is skipped now; any other listing
failure refuses, exit 2. worktree_registrations returns None when both
listings fail. Discovery refuses on it. The prune's after-check, a
branch delete and --branches stop that act. The report records the
checkout as unreadable.

Two cases: a mode-000 lane root with an empty inventory, and a git shim
that fails `worktree list`. Both now exit 2.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Walk every estate directory for dependents; a partial scan deletes nothing

#170 A11. The estate walk entered a repository only through worktree
containers, so it never saw a plain directory inside a repository (it
found none of xFactory/xFactories/* on Eagle). A clone there that
borrowed a lane clone's objects was unseen, and the clone was deleted
from under it. An empty estate root read no dependents at all, and an
unreadable directory read as one with nothing in it.

walk_estate visits every directory but caches, tool environments and
site-packages. It never follows a symlink or enters a .git, and it
collects .lane-state directories for #170 G6. Whatever it, or the
alternates and config reads, could not read makes the dependents scan
partial. A partial scan, a missing estate root, or a tree outside the
estate root keeps a clone and a scratch directory. Both are removed by
deleting a directory, and a dependent nobody saw would lose its objects.
Worktrees are untouched by this: their objects live in the checkout's
repository, which a worktree removal leaves.

Two cases: a --shared dependent under host/vendor/ makes the clone
load-bearing, and a mode-000 directory in the estate keeps it.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read every other lane's control root before granting a tree

#170 G6 (Copilot after the cap). Other lanes' claims were read only
under this lane's control-root parent and the LANES_LANE_STATE_ROOT
override. A lane in a nested estate keeps its .lane-state beside its own
recorded dir, so its claim was missed, the tree was not contested, and
--yes could take it.

The parents read now also include $PROJECTS_ROOT/.lane-state, every
.lane-state the estate walk found, and the parent of every lane's
recorded dir. The recorded dirs come from one `git grep` over the
register's lane logs on origin/<branch>, parsed as lanes-edit.sh's
payload_subfield parses them. A register that cannot be read refuses:
whose claims were missed would be unknown. Lane names compare without
case, as Amendment 15 has them.

The case puts another lane's claim on one of this lane's trees under
<estate>/group/.lane-state, and under a directory outside the estate
that only the register names. Both keep the tree.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep trees whose hidden edits, nested clones, tags or submodule refs would go

Four ways tree_guards let a removal take work no rescue carried:

- #170 A2: a file flagged skip-worktree or assume-unchanged reads clean
  in `git status`, and `git add -A` skips it, so its edit was in no
  rescue. hidden_edits hashes every flagged file present in the tree
  against its index blob, and a difference keeps the tree. A flagged
  file that is absent (a sparse checkout's) is no edit.
- #170 A3: the nested-repository walk passed over venvs and node_modules
  before it looked for a .git, so `pip install -e git+...`'s clone in
  <venv>/src/<pkg> was deleted with the tree. nested_repos skips only
  bytecode caches, and a directory it cannot read keeps the tree.
- #170 item 4: a clone's publication check read branches and the
  stash, not tags. unpublished_ref checks every branch, every tag
  (peeled to its commit) and HEAD against refs/remotes/origin in one
  rev-list. A tag that names no commit cannot be checked, and keeps it.
- #170 item 5: only a submodule's HEAD was checked. A worktree's
  submodules keep their repositories under .git/worktrees/<id>/modules/,
  so their branches, tags and stash went with the tree. Each initialized
  submodule now gets the same check against every remote of it, plus
  its stash. A `git submodule status` that fails keeps the tree; it used
  to skip the submodule checks.

Cases: both flags, a sparse checkout's absent file (still removed), a
clone in .venv/src, a clone's annotated tag on a commit no branch has,
and a submodule's unpublished branch and stash.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Judge an ignored entry by its own name, not an ancestor's

#170 A6. ignored_paths dropped an ignored file when any ancestor was
named like a cache or build output. With --directory an ignored cache or
build directory is listed as itself, so a path beneath a directory named
`build` is an ignored file in a tracked directory. docker/build/prod.env
was deleted unarchived while top.env beside it was archived.

Only the entry's own basename is compared with CACHE_NAMES, VENV_NAMES
and BUILD_NAMES now. An ignored /build/ directory is still build output
and is not archived.

The case puts prod.env in the tracked docker/build/ beside an ignored
/build/ and checks the archive holds prod.env and top.env and nothing
of build/.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Leave other people's open branches alone: rescue, and keep the remote

Two ways --yes acted on a branch someone else's pull request owns:

- #170 A7: push+remove pushed the lane's unreviewed commit onto a
  teammate's OPEN PR branch, because the decision ignored the merge
  state. Where an open PR names the branch, where origin's tip carries
  another lane's Lane: trailer, or where whether a PR names it could not
  be read (gh unavailable), the commits now go to
  rescue/<lane>/<slice>-<UTC> and origin's branch is left as it is.
- #170 A8: a register LANDED line beat gh's live OPEN for the same
  number, so a LANDED #21 typed for #20 deleted the remote branch of the
  still-open #21 and closed it. The tree and its local branch still go
  (the work is on origin), but the remote branch is never deleted while
  gh answers OPEN for it. The same holds under --branches.

Cases: the open PR, another lane's trailer and LANES_NO_GITHUB each send
the commit to a rescue branch with origin's branch unmoved, and a wrong
LANDED number leaves the open PR's branch on origin.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Rescue before a prune, bundle what only a reflog names, self-check each removal

Four #170 items that share one mechanism: before a removal the sweep now
asks git which commits it would take and where each one is kept.

- The loss plan. A worktree's removal takes its git directory: its HEAD
  and that HEAD's reflog, plus the branch and its reflog where the
  branch is deleted after it. A pruned registration takes the same,
  read from its git directory. A clone takes every ref but its
  remote-tracking ones, and every reflog. One rev-list says which of
  those commits no origin ref, surviving branch, tag or stash, push
  seen on origin this run, or bundle head of this run reaches.
- A4: `prune` dropped the only pointer to a detached commit. A
  registration whose git directory names such a commit is now
  rescue+prune: a rescue branch pushed where there is an origin, a
  bundle, then that one registration removed.
- A5: commits only a reflog names (an amend, a reset, checking out away
  from a detached commit) were lost on removal and on delete_local_branch.
  They go to a bundle of their own through temporary
  refs/lane-worktrees/* refs, which are deleted once it is verified.
- Item 8: a bundle that is the only copy of what it holds (bundle+remove
  with no origin, the reflog bundle, the untracked bundle below) gets a
  rescues.tsv row (origin `-`, branch `bundle:<file>`). `--expire` reads
  it as "no other copy" and never expires that archive.
- A9: the WIP rescue pushed every untracked, un-ignored file, a service
  account key included. The pushed WIP commit now carries the tracked
  changes only. A second commit holding the untracked files goes to the
  bundle alone, as the ledger's only copy. --push-untracked pushes them
  as before.

THE SELF-CHECK. Right before each removal (and each prune) the same
question is asked once more. If anything the removal would take is
neither on origin nor in a verified bundle, the removal is refused,
DISPOSITION.md says so, nothing after it is acted on, and the exit is 2.
LANE_WORKTREES_SEAM_NO_LOSS_BUNDLE=1 is the suite's seam: it skips the
reflog bundle so the case can prove the self-check stops the loss.

Cases: a pruned detached commit (rescued, bundled), a reflog-only
commit (bundled, ledger row), the self-check with the seam (exit 2,
both trees left, the refusal in DISPOSITION.md), untracked files with
and without a tracked change (never on origin, in the bundle,
temporary refs gone), --push-untracked, and an origin-less
bundle+remove archive surviving `--expire --yes` at retention 0.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Run the push hooks where git-lfs is configured

#170 A10. `git push --no-verify` skips git-lfs's pre-push hook, which is
what uploads LFS objects. A rescued branch therefore reached origin as
pointers only, and the tree, the one copy of the objects, was then
removed.

Where the repository has any filter.lfs.* setting (the global one
included) the push now runs its hooks. A hook that refuses fails the
push and leaves the tree. Without git-lfs, --no-verify stays, so an
unrelated pre-push hook cannot hold a rescue up.

The case installs a pre-push hook that records itself. It runs with
filter.lfs configured and does not run without it.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep scratch that holds a tree or a repository

#170 item 7 (Copilot round 3 on #168). A scratch directory was archived
and removed whenever it had no .git of its own, so a worktree or clone
nested beneath it went with the rmtree. A FOREIGN or live tree was not
protected either, and nor was an unpushed commit.

A scratch directory is now kept while any tree of the table lies under
it, or any .git does at any depth (bytecode caches aside). A directory
under it that cannot be read keeps it too. The tree table retires its
own children first, and the scratch goes on a later sweep. The check is
made again at the act and again after the tar.

The case nests a clone with an unpushed commit two levels down in a
scratch directory. --include-scratch --yes keeps the scratch and the
commit.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Check --live paths, want the writer count for own trees, stop on NOT WRITTEN

Three gaps in how --yes takes the coordinator's word and the register's:

- #170 B4: a misspelt --live path was accepted in silence, so the
  writer it was meant to protect was not protected. Each --live path
  must now name a tree of the table (it, or a path inside it), and
  `--live none` stands alone. Anything else is usage, exit 64, decided
  before any repository is fetched.
- #170 B5: a tree this session recorded skips the transcript check,
  because the transcript is this one. --live was demanded only when the
  holder read as this session, so with the holder read as none, --yes
  removed trees this session's own writers could be in. Any inventory
  record whose writer is the caller now demands the count too.
- #170 B6: removals went on after a NOTED line failed. The first line
  the register refuses now stops the sweep. Every later tree and item
  is left as the table found it, with the reason, and the exit stays 1.

Cases: a typo'd --live path and `--live none <path>` exit 64 and touch
nothing; a tree recorded by this session refuses without --live and
goes with --live none; with the register refusing, the first tree goes,
the second stays, and exactly one line was attempted.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Count --branches by origin's own ref, never by upstream or an open PR alone

Two ways `--branches --dry-run --porcelain` gave #163's gate the wrong
answer (Copilot after the cap on #168):

- G2: every OPEN PR's branch was left out of the retire count, even a
  lane-owned one holding commits origin lacks, so the gate read 0. The
  delete protection stays. Such a branch is now counted unless
  refs/remotes/origin/<branch> holds its tip, and a read that failed
  counts it too.
- G7: a lane-owned branch was judged by its configured upstream alone.
  A branch made with `checkout -b X origin/main` tracks main, so once
  pushed under its own name it still read "ahead" and the gate read 3.
  Publication is now checked in origin's ref for the branch's own name
  first, and a branch origin holds that way is not listed. Unknown
  states are still counted.

Cases: an unpushed lane branch named by an open PR makes the dry run
exit 3 and survives --yes; a branch made from origin/main and pushed
as itself makes it exit 0.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Remove a tmp.* only on proof, and read hidden processes as unknown

#170 items 1 and 2 (Copilot on #168), with the coordinator's defaults,
which the PR body states for Brett Heap to overrule:

1. --include-sandboxes removed any account-owned tmp.* over an hour old
   with nobody in it, with no proof a suite made it, so a person's
   `mktemp -d` checkout or saved scratch could go. A tmp.* is now
   removed only with a suite's mark whose pid is gone (pytest's .lock,
   tests/run.sh's new .openrepotools-run marker, or run.sh's run-root
   layout of basetemp/ beside tmp/), or when nothing in it has been
   written for aging_days (14). Age is the newest mtime anywhere inside,
   not the top directory's. Any other tmp.* is a `list` row, kept. A
   marker's pid is read again at the act. tests/run.sh writes its pid
   into each run root it makes.
2. The /proc scan read a same-account process whose entries cannot be
   read (not dumpable) as absent, so its tree could be removed under
   it. Such a process is now in ProcScan.unknown and placed by what can
   be read: absolute paths in its command line, and its parent's
   working directory. A tree, scratch directory, cache owner or sandbox
   it is placed in is of unknown liveness and is kept, at the read and
   again at the act. One placed nowhere (ssh-agent re-parented to init:
   three on Eagle today) holds no tree, so a sweep is not frozen for
   ever. One whose status cannot be read at all could be anywhere. A
   process of another account is never a writer here. The human table
   names every unreadable pid in a note. Uid is read from
   /proc/<pid>/status, because a non-dumpable process's /proc entry is
   owned by root.

Cases: four tmp.* directories (unmarked and two hours old: listed and
kept; twenty days old, pytest-marked and run-root-shaped: removed), and
a non-dumpable child standing in a tree, which is kept. The existing
sandbox and report cases now age every file, not only the directory,
and the killed sandbox carries pytest's mark.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document what #170's fixes change in the sweep

The manual's sweep section and --help now say:

- under --yes the register is fetched first, and a failed fetch refuses;
- each repository's fetch pins its refspec;
- other lanes' claims are read wherever #97 keeps them;
- an unreadable lane root, registration list or register refuses;
- --live must name a tree, and a tree this session recorded wants the
  count;
- the new keep rows (hidden edits, nested clones anywhere, clone tags,
  submodule branches, tags and stash, a partial dependents scan) and
  the rescue+prune row;
- untracked files go to the bundle only, unless --push-untracked;
- rescue rather than push onto another's branch, and no remote delete
  while gh answers OPEN;
- the loss plan, the reflog bundle and the self-check, and the stop on
  NOT WRITTEN;
- the LFS hooks, ignored files judged by their own name, scratch kept
  while trees lie under it;
- tmp.* removed only on proof, the bundle: rows that --expire never
  expires, and the exit contract's new 2 and 64.

The --yes switch and its paragraph go in the next commit, with the
switch itself.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hold the existing cases to the register fetch, the untracked bundle and B5

Three of #170's fixes change what older cases see. A proof run of the
sweep module on CI (the workstation's pytest lock never came free)
failed these seven at the previous head:

- The table case asserted that the WIP rescue pushed the untracked
  new.txt. It now asserts new.txt is not on origin, and that the
  ledger's bundle: row for the dirty tree holds it (A9).
- The four refusal cases and the unreadable-claim case snapshot the
  estate around a refused --yes. --yes now fetches the register first
  (A1), which writes FETCH_HEAD in the workspace repository and nothing
  else, so the workspace is left out of those snapshots.
- The unreadable-record case wrote its record as this session's
  writer. B5 then refused first, with its own reason, so the record is
  written by the earlier session the fixture uses everywhere else.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Walk the whole estate only where a removal deletes a directory

Measured on Eagle, the whole-estate walk A11 asked for is 683,360
directories: 541,236 of them in xFactory alone, mostly spec trees. It
took 25 s warm and 397 s cold, where the gate's dry run took 15 s at
45fa45d. #163's gate would pay that at every lane-end, for nothing a
worktree's removal can break: a worktree's objects are its checkout's,
and the removal leaves them.

The whole walk now runs only where a removal deletes a directory: a
clone the table could take, or --include-scratch. Otherwise the
estate's shape walk is enough, as before. Other lanes' claims (G6)
follow #97's own rungs for every lane: the override, the parent of
every lane's recorded dir in the register, and $PROJECTS_ROOT. Each is
read beside this lane's own root and beside every checkout the shape
walk finds. So they no longer depend on the whole walk. A lane cannot
claim a tree without a register row.

The G6 case's nested checkout is now a repository, as a lane's is.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse an inventory row that carries no id

#170 G9 (Copilot after the cap, on 87da332). lane_facts skipped a
`lane-trees` row whose id was empty. A tree such a row names outside the
scanned roots was then never discovered, and the dry run could exit 0
and clear #163's gate.

A nonempty row with no id is now an unreadable record. Discovery
refuses it, exit 2, as it refuses one of an unknown schema.

The case gives the inventory one id-less row naming a tree outside both
roots: exit 2, where 45fa45d exits 0.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fail closed when the bytecode check cannot read the pathspec

#170 G13 (Copilot after the cap, on 45fa45d). bytecode_in_pathspec threw
both git reads' stderr away, and its awk exits 0 on no input. A read
that failed therefore printed nothing: a malformed magic pathspec, or an
index git cannot read, makes both `add --dry-run` and
`diff --cached` exit 128. `pathspec-check` then reported the pathspec
clean, and the commit path let it through.

Each git status is kept now, and the function answers 3 when a read
failed. `add --dry-run`'s exit 1 is not a failure: it means a named
path is ignored, which the add would not stage. `pathspec-check`
refuses with exit 2. The commit path refuses with exit 2 before it
stages anything.

The shell suite's #162 block gains four assertions: `pathspec-check
':(bogus)x'` exits 2 and says the read failed, and a commit of that
pathspec exits 2 and commits nothing. At 45fa45d the first exits 0 and
the commit fails later, at the add, with exit 6.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document G9's id-less row and G13's failed read

The manual's inventory paragraph now names a row with no id among the
unreadable records that refuse a sweep. The pathspec-check usage line,
and the comment above the subcommand, now say exit 2 also covers a read
git could not make.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read a .git file that is no pointer as no repository, not as unreadable

The read-only dry run over this lane's estate on Eagle kept every
--include-scratch directory as "the dependents scan was partial". The
cause was two empty .git files that uv keeps in its caches to stop git
looking upward. git_common_dir answers '' for those as it does for an
unreadable .git, and dependents_map counted both as unread.

A .git file that can be read and is no gitdir pointer is now skipped
as no repository. Only one that cannot be read leaves the scan partial.

The case puts uv's marker in the estate. A clone taken on a word is
still removed: the scan is whole.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Switch --yes back on: remove LANE_WORKTREES_ENABLE_YES

5f4bd84 switched --yes and --expire --yes off until #170's data-loss
paths were closed. Every item #170 lists for --yes now has a case that
fails against 45fa45d and passes here: items 1, 2 and 4-8, A1-A11,
B4-B6, G1, G2, G5-G7, G9 and G13. Before every removal the sweep also
asserts, of git itself, that each commit the removal would take is on
origin, in a ref that stays, or in a bundle this run verified. If not,
it refuses with exit 2 and a DISPOSITION.md line.

The switch, its refusal, the --help paragraph and the manual's
blockquote are gone. The suite's estates no longer set the variable.
The switched-off case is replaced by one that runs --yes and
--expire --yes with no variable in the environment.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse a clone's removal when a reflog directory under it cannot be listed

#174 Copilot round 1 (lane-worktrees `_loss_plan`): the loss plan of a
clone collected its reflogs with `os.walk`, which passes over a directory
it cannot list in silence. A commit only `logs/refs/heads/<b>` named was
then in no plan: no bundle took it, the self-check asked the same blind
question, and the clone was deleted with it.

`reflog_files` walks `<gitdir>/logs` with an error handler: a `logs`
directory that is not there is an empty answer, anything else it cannot
list makes the loss plan unknown, and the clone is left in place.

The case seals a clone's `logs/refs/heads` (mode 000) with an experiment
only that branch reflog names, and wants the clone and the commit kept.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a tree whose submodule names an unpublished commit only in its reflog

#174 Copilot round 1 (lane-worktrees `tree_guards`): a submodule's
experiment, made detached and checked out away from, is in no branch, tag
or stash of the submodule - only its reflog names it. Every submodule guard
passed, and the tree's removal took the submodule's repository, which lives
under the tree's own git directory, with the experiment in it.

The submodule's reflogs are read now (`unpublished_reflog`, through
`reflog_files`, so a directory it cannot list is unknown too): any commit
they name that no remote of the submodule holds keeps the tree, and says so.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Archive every ignored entry selected, whatever its ancestors are named

#174 Copilot round 1 (lane-worktrees `_tar`): #170 A6 selects an ignored
entry by its own name, so `docker/node_modules/prod.env` - an ignored file
in a tracked directory - is selected for the archive. The archive's member
filter then dropped any path with a cache- or venv-named part anywhere in
it, `top.env` beside it kept the archive non-empty, it verified, and the
tree was removed without prod.env.

The filter now judges only the parts of a member's path BENEATH the entry
it was added for, and always carries the entry itself; and the archive is
read back for every entry selected, so one missing leaves the tree.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Never remove a cache directory that holds a repository

#174 Copilot round 1 (lane-worktrees `_caches`): a tree kept because a
clone is nested in its venv (`pip install -e git+...` clones into
`<venv>/src/<pkg>`, #170 A3) still gave up its caches, and `.venv` is one.
`--include-caches --yes` removed it, and the clone and its unpushed fix
with it.

A cache candidate that is a repository, holds one anywhere inside, or
holds a directory that cannot be read is now a `keep` row when the table is
built, and asked again right before the removal. The venv case of A3 runs
`--include-caches --yes` too and wants the clone's commit kept.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* List an unmarked tmp.* that holds a repository, whatever its age

#174 (found auditing that a check guards every removal): `--include-
sandboxes` removed an unmarked `tmp.*` untouched for `aging_days` on age
alone - the second half of #170 item 1's default - so a person's `mktemp -d`
checkout with an unpushed commit in it went. No suite's mark says such a
directory's repositories are fixtures, so one that holds a repository, or
a directory that cannot be read, is listed and never removed; the check is
asked again right before the removal. A marked one is still the suite's,
and its repositories are its fixtures.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Bundle a branch's reflog-only commits and self-check before --branches deletes it

#174 Copilot round 1 (lane-worktrees `_act_item`): the loss plan, the
bundle and the self-check guarded a tree's branch delete only. With
`--branches --yes` a merged branch no worktree holds was deleted with
`update-ref -d`, which takes its reflog too - the last pointer to an
experiment committed on it and reset away from.

A branch item now asks what the delete would take (its tip and every
commit its reflog names, against origin, the other branches, tags, the
stash, this run's pushes and bundles), bundles what nothing keeps, with a
`bundle:` ledger row, and asks once more right before the delete. A refusal
there stops the sweep, exit 2, and DISPOSITION.md says so, as it does for a
tree; a refused item is no longer counted in the register's "swept branches"
line. The bundle helpers take a repository and a name instead of a tree.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hold back untracked files by the act's own snapshots, and keep untracked git-lfs content

Two #174 Copilot round 1 findings in the WIP rescue (lane-worktrees
`_wip_rescue`), both about what goes to a bundle only (#170 A9).

Whether untracked files were held back was read from the table's status,
taken minutes earlier. A tree with only a tracked change when it was read,
and an untracked key file by the time of the rescue, had its full
snapshot - key included - pushed to origin. The pushed snapshot is now
always the tracked-only one (unless `--push-untracked`), and what the full
snapshot holds beyond it is what goes to the bundle alone.

An untracked file git-lfs filters is snapshotted as a POINTER: its bytes
go to the local LFS store, which no bundle carries and the removal may
take, so the bundle that was its only copy held no payload. Where git-lfs
is configured, such a tree is now `keep` - in the table, and again at the
act before anything is pushed (a `_Keep` found at the act leaves the tree
as the table would have).

The cases make the file arrive between the table and the act with a `git`
that writes it on the hidden-edit guard's second call.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read a clone's annotated tag through its peeled commit, and bundle the tag object only origin lacks

#174 Copilot round 1 (lane-worktrees `lost_tips`, `_loss_plan`): a
clone's loss plan read every ref's raw `%(objectname)`, so an annotated
tag arrived as its tag object. `lost_tips` reported any object that was no
commit as lost whatever kept it - so the tag was bundled, then the
self-check reported it lost again, refused the removal and halted the
sweep, for every clone with an annotated tag.

A tag object is now kept where a SHA in the keep list names it - a
verified bundle's head, or origin's own copy of that very tag object,
read with `ls-remote --tags` (none when origin cannot be asked, so the tag
is bundled) - and its history is its peeled commit's, asked like any other
commit. A SHA in the keep list counts through the commit it peels to.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document what #174's first Copilot round changes in the sweep

The manual's table and the command's own help now say that a submodule's
reflog-only commit keeps its tree, that untracked git-lfs content keeps
its tree and untracked files are read from the act's own snapshots, that
a clone's reflog directory that cannot be listed keeps the clone and its
annotated tags are bundled where origin lacks that very tag object, that
the ignored archive carries every entry selected whatever its ancestors
are named, that a `--branches` delete has its own loss plan, bundle and
self-check, and that neither a cache nor an unmarked `tmp.*` sandbox that
holds a repository is ever removed.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a tree whose git directory keeps a submodule repository with work no remote of it holds, a deinitialized one's included

#174 Copilot round 2 (lane-worktrees `tree_guards`): the submodule
guards read only what `git submodule status` lists as checked out. A
DEINITIALIZED submodule shows `-`, its working tree is gone, and its
repository - branches, a stash, reflog entries - stays under
`<gitdir>/modules/<name>`, which the tree's removal takes. A branch no
remote held went with the tree.

Every repository kept under the tree's git directory's `modules/`
(nested ones included) is now read: a branch, tag or HEAD no remote of it
holds, a stash (read as its ref, since `git stash list` wants a working
tree) or a reflog entry no remote holds keeps the tree, and so does a
`modules/` directory that cannot be read.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a tree whose submodule has a skip-worktree or assume-unchanged edit that git status hides

#174 Copilot round 2 (lane-worktrees `tree_guards`): #170 A2's
hidden-edit guard read the superproject only. A file flagged
skip-worktree or assume-unchanged in a submodule and edited there reads
as clean in that submodule exactly as in the superproject, no rescue
carries it, and it went with the tree. The same `hidden_edits` read now
runs in every checked-out submodule, and flags that cannot be read keep
the tree too.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Pass over nothing but .git when proving a removal takes no nested repository

#174 Copilot round 2 (lane-worktrees `nested_repos`): the walk skipped
directories named `__pycache__`, `.pytest_cache`, `.mypy_cache` and
`.ruff_cache`. It is the proof that a removal takes no repository, so a
checkout under a directory with one of those names was missed: a scratch
holding one was archived without it (the archive leaves caches out) and
removed. NESTED_SKIP is gone and every directory but `.git` is entered.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read another lane's claims in every .lane-state the whole-estate walk finds

#174 Copilot round 2 (lane-worktrees `Sweep.gather`): where the whole
estate is walked - a clone the table could take, or `--include-scratch` -
`walk_estate` finds every `.lane-state`, one in a plain directory inside
a repository included (`host/vendor/.lane-state`), but the sweep threw
that list away and read claims only beside the shape walk's checkouts and
the register's recorded directories. With no log naming that place, a
clone another lane claimed from there was removed. The walk's control
roots are now read for claims as well, before liveness and the table.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Never push a lane's commits onto a branch gh still answers OPEN, whatever a LANDED line says

#174 Copilot round 2 (lane-worktrees `MergeEvidence.judge`, `_decide`):
a register LANDED line naming a pull request gh still answered OPEN, with
the local tip ahead of the PR's head, made the verdict "moved". That
verdict dropped gh's OPEN answer, so #170 A7's rule never fired and
push+remove put the lane's unreviewed commits onto the open pull
request's branch. The moved and merged-elsewhere verdicts now carry gh's
OPEN numbers, and a branch gh answers OPEN goes to a rescue branch, with
origin's branch left as it is.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Never prune a gone worktree's registration whose git directory keeps a submodule repository with work

#174 Copilot round 2 (lane-worktrees `_decide`, `_act_prune`): a
registration whose directory is gone still keeps its submodules'
repositories under `<admin>/modules/`, and the prune - which read only
the superproject's HEAD and reflog - removed them, a branch no remote held
with them. The same submodule-repository read as the tree guard now keeps
such a registration (`keep`), and is asked again right before the prune.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a clone with a local git-lfs store when its commits would go to a bundle only

#174 Copilot round 2 (lane-worktrees `_act_tree`): a clean clone with a
commit only its reflog named had that commit bundled and was removed with
`.git/lfs/objects`. A bundle carries LFS pointers, never their bytes, and
no push uploaded them, so the bytes behind those pointers were lost. A
CLONE whose local LFS store holds anything (or cannot be read) is now kept
wherever a bundle would be the only copy of a commit - the `bundle+remove`
disposition and the reflog-only commits of step 2b alike.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Count a suite's .lock or run.sh marker as provenance only when it names a pid

#174 Copilot round 2 (lane-worktrees `sandbox_provenance`): an EMPTY or
malformed `.lock` (or `.openrepotools-run`) counted as a suite's mark,
so a person's two-hour-old `tmp.*` with a checkout in it was removed -
past coordinator default 1's 14-day rule and the repository guard both. A
mark now proves a suite made the directory only when it names a pid; the
run-root layout proof (`basetemp/` beside `tmp/`) is unchanged.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document what #174's second Copilot round changes in the sweep

The manual's table and the command's own help now say that every
submodule repository a tree's or a gone registration's git directory keeps
- a deinitialized one's included - keeps it where it holds work no remote
of it holds, that a submodule's hidden edits keep the tree, that a
repository under a cache-named directory is still found, that the
whole-estate walk's `.lane-state` finds are read for claims, that gh's
OPEN wins over a LANDED line, that a clone's local git-lfs store keeps it
where a bundle would be a commit's only copy, and that a suite's mark must
name a pid.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read a submodule repository kept under a git directory as that git directory alone, so a gone worktree's is read rather than refused

A submodule repository under `<gitdir>/modules/` names its working tree in
`core.worktree`. Where that directory is gone - every registration whose
worktree directory went, and a submodule removed after its deinit - every
git command run in the repository dies on "cannot chdir", so the guards
of this round's first and sixth commits read every such repository as
unreadable: a gone worktree with any submodule was kept for that reason
alone, never read. The proof run on the round's head showed it (the
prune's reason said the branches "could not be read" where the test
expected the unpublished branch named).

The submodule-repository reads now run each git command there with the
repository's own `objects/` as its working tree (`GIT_WORK_TREE`, through
`git_env(path)`); they are reads that need none - refs, reflogs,
rev-list, cat-file - and a branch no remote holds is named again.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a tree or registration whose submodule repository has an annotated tag object no remote of it holds

#174 Copilot round 2 (lane-worktrees `module_repos_why`, submodule
guards): `unpublished_ref` asks whether an annotated tag's COMMIT is
published, never its tag object, so a submodule's local annotated tag on
a published commit passed every guard, and its message, which nothing
else held, went with the tree. A clone's own tags have been bundled since
round 1, but a submodule's are in no loss plan.

At the act, before anything is rescued, every submodule repository the
tree's (or a gone registration's) git directory keeps is asked: each
annotated tag object must be answered by some remote of it in
`ls-remote --tags` as that very object, else the tree is kept, and so it
is where no remote could be asked. It asks remotes, so the dry run, which
asks none, still reads `remove`.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read the gone worktree's kept submodule repository in its regression the way the sweep reads it

The proof run on the round's head passed every assertion of
`test_a_gone_worktrees_kept_submodule_repository_is_never_pruned` about
the sweep - the registration kept, the reason naming the unpublished
branch - and then failed …
brettheap added a commit that referenced this pull request Oct 7, 2026
Main is 500687c, then 5eb3d5f (#174's squash, whose tree is 4ea84c8's
exactly), then 2fb854f (#179). This branch carries 4ea84c8's own history, so
git merged on base 500687c, and #179's changes to #174's text conflicted with
#174's text as this branch carries it. No conflict touched a line #175
changed. Each file is resolved to main's text plus #175's own diff
(4ea84c8..88b2923): a three-way merge on base 4ea84c8, which merges every file
without conflict. Main's text is taken in every conflicted region:
  lane-worktrees               4 regions: #179's SEAM_NO_PROCSCAN process-scan
                               seam (2 regions), its shell-quoted report command
                               (#170 E6/E7), and `self._unread(c, ...,
                               record=False)` in place of
                               `self.unreadable.setdefault(c, ...)`
  lanes-edit.sh                1 region: #179's C-locale, unquoted reads in
                               bytecode_in_pathspec (#170 E1, E2); this file
                               is now main's exactly
  tests/test_lane_worktrees.py 4 regions: #179's `_dead_pid()` in place of the
                               literal 999999 (3), and its "#170: test
                               honesty, portability and nits" section at the
                               end of the file
The files git merged on its own (docs/README-lanes.md, tests/run.sh,
tests/test_lane_helpers.sh, tests/test_lane_worktrees_report.py, and #179's
other files) are byte-identical to the same base-4ea84c8 merge.

`git diff origin/main` of this merge is byte-identical to `git diff 4ea84c8
88b2923` (index and @@ lines stripped, at -U0 and -U3): #175's 6 files,
+1107/-14.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 7, 2026
…e's trees, branches, scratch, caches or residue, --inventory-only as the logged door, and lane-worktrees add records every tree it makes (#163) (#175)

* Add lane-worktrees: the sweep that retires a lane's abandoned worktrees

A swap that does not finish gracefully leaves its writers' trees where
they stood, and nothing removed them: a person archived, verified and
removed them by hand. `lane-worktrees sweep <lane>` makes that one act,
dry run by default, under one rule: nothing is deleted that is not
first on origin or in a bundle under the sweeps directory, and a live
writer's tree is never touched.

Which trees are the lane's is #97's inventory and the disk, never the
derived index (Amendment 14(b)); a tree the inventory does not name is
FOREIGN and is left unless --include-foreign and a --word, and one
another lane's inventory names is never taken. Who may act is #97's
reconciliation, read before any write including the fetch: a lane bound
elsewhere, held by another live session, managed-owned or unreadable is
refused (exit 2); a lane this session holds acts only on the writer
count the coordinator states with --live.

"Merged" is the register's LANDED line for the branch's PR or gh's
MERGED for it, never ancestry alone. Unpublished commits are pushed
under the branch's own name, never its upstream's (a branch made from
origin/main tracks main), or to rescue/<lane>/<slice>-<UTC> where
origin diverged; dirty work becomes a WIP commit built through a copy
of the index, so a refused push leaves the tree exactly as it was.
Removal re-proves the head and the content at the moment it happens.

Also --branches, --include-scratch, --include-caches,
--include-sandboxes, --links, alternates and local-remote detection
(LOAD-BEARING clones are never removed), sweep --expire with 90-day
retention that never expires an archive whose rescue left origin, and
the porcelain contract lane-end's gate (#163) reads: 0 nothing to
retire, 3 something to retire, 2 refused.

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test the sweep against every row of #162's disposition table

tests/test_lane_worktrees.py builds a lane's estate under tmp_path: a
bare origin reached through a url.insteadOf for a GitHub-shaped URL, the
lane's checkout and both worktree roots, and fakes for lanes-edit.sh,
gh and tmux. One lane holds a tree in every state, and the dry run is
held to the table row for row and to a whole-estate snapshot that does
not move. The same lane under --yes is held to every act: pushes,
rescue branches seen on origin, the WIP commit's subject and parent,
the bundles, the ignored-file archive without bytecode, a manifest
that verifies, and one register line per tree.

Also: a refused push leaves the dirty tree byte for byte; a live writer
(a process's cwd, a tmux pane) is never touched; this session's --yes
needs its writer count; bound elsewhere, held elsewhere and unreadable
are refused with 2 and change nothing; merged by register and by gh
against a branch on main by ancestry alone; no gh, nothing merged;
--include-foreign takes a word and never another lane's tree;
--branches; scratch, caches and sandboxes; --links; a LOAD-BEARING
clone; --expire at 89/90/120 days; the porcelain exit codes; and two
cases on the REAL lanes-edit.sh, for the inventory, the register line
and a lane bound on another host.

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Place lane-worktrees as the seventeenth installed file

`openRepoTools --install` places `lane-worktrees` at the end of
INSTALLABLES, so every index a test takes still names the file it did.
The file count moves 16 -> 17 and the artifact count 30 -> 31 wherever
they are stated, with the count history extended rather than restated;
the receipt sentence now says 31 artifacts, 29 rows. --help names the
new command.

The hygiene suite compiles `lane-worktrees` beside `lanes-index`: the
two shipped commands that are Python and have no .py suffix for the
glob to find.

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document the sweep in the lanes manual

"Retiring a lane's worktrees": the rule, which trees are the lane's and
who may act, the disposition table as implemented in the order it is
decided, the other rows (--branches, scratch, caches, sandboxes, links,
bundles), the sweeps directory and its manifest, the one register line
per tree, retention and sweep.conf, the porcelain exit contract for
lane-end's gate, and the two protocol lines the act assumes, proposed
for the amendment that ratifies it.

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Walk the estate for --links with scandir

`os.walk` lstat()s every entry, and the estate on Eagle is tens of
thousands of directories: a read-only `--links` dry run took 245 s.
A scandir walk reads each entry's kind from the directory read itself,
so only a symlink or a `.git` file costs a call of its own. Measured
on the same estate: 16.7 s, with the same 403 broken links found (99
of them worktree gitdir pointers).

Part of #162.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a test run's bytecode, cache and temp out of the worktree

A suite run left tests/__pycache__, .pytest_cache and, when killed, its
sandboxes in whichever worktree ran it; the estate cleanup found those
were 968 of 1,000 removals (#162, causes 3 and 4). tests/run.sh now
sends bytecode to ${XDG_CACHE_HOME:-~/.cache}/openRepoTools/pycache,
turns pytest's cache off, and roots --basetemp and TMPDIR under one run
root in ${XDG_STATE_HOME:-~/.local/state}/openRepoTools/tmp/<UTC>-<pid>/
that an EXIT trap removes however the run ends.

The suite now runs in the background and is waited for: bash runs a trap
only once its foreground child returns, so a TERM used to wait out the
whole suite. The INT and TERM handlers stop the suite, then exit, so the
EXIT trap removes the run root and, on the mkdir path, the lock. The
lock is computed before TMPDIR moves, so it stays the workstation's.
The repository venv is used when it has pytest, first on PATH so the
command line still reads `python3 -m pytest`.

tests/test_run_wrapper.py proves it against a stub suite: the
relocation, the run root gone after a pass, a failure, a TERM and an
INT, under flock and under the mkdir lock, and the venv.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Name the repository's virtual environment on --install

One virtual environment per repository, outside the estate, is the
convention the sweep's --include-caches row assumes (#162, cause 3): a
venv/ inside a worktree is a leftover to remove. --install now names
this repository's, ${XDG_CACHE_HOME:-~/.cache}/openRepoTools/venvs/
openRepoTools, and says whether it is there; when it is not, it prints
the two commands that make it. It never makes one: that needs pip and
the network, and an install from a checkout needs neither.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Seed the workspace .gitignore with bytecode and cache rules

Cause 19 of the cleanup analysis: a handoff's attachments were committed
with a whole virtualenv's __pycache__ inside them, and brett-wip carries
703 bytecode paths in its history. `wip init` now adds __pycache__/,
*.pyc, *.pyo, .pytest_cache/, .mypy_cache/, .ruff_cache/, node_modules/,
.venv/ and venv/ to the seeded .gitignore, in wip_substitute - the one
function both step-7 paths go through - so the bytes a seed writes and
the bytes a re-run compares are the same. A line the template already
carries is not repeated, so a template that adopts them upstream seeds
them once.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse a workspace commit whose pathspec carries bytecode

The .gitignore lines keep a new workspace clean; an older one without
them still stages whatever a pathspec names. commit_push now asks git
what its pathspec would stage (git add --dry-run, which honours
.gitignore exactly as the real add does) and refuses, exit 2, nothing
staged, when any path has a __pycache__, cache, node_modules, venv or
site-packages component or is *.py[co] - and offers the .gitignore
lines that workspace lacks, with the one command that adds them.

Attachments are committed by hand, so the same question is a
subcommand: `lanes-edit.sh pathspec-check <path>...` answers 0 clean,
2 with the offending paths on stdout and the offer on stderr, 64 usage.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Run the estate report once a day from lane-start

The report is the net under every actor that never runs lane-end, and
it only works if it is read every day. lane-start now runs
`lane-worktrees sweep --all --dry-run --report` once per UTC day per
workstation, after the row is written and before the launch: the first
start of the day takes report-<YYYYMMDD>.stamp under the state
directory with an atomic `set -C` create, removes older stamps, and
starts the report detached, stdin, stdout and stderr closed, writing
reports/<UTC>.md. Every step either works or is skipped in silence, so
the report never delays a start and never fails one. --dry-run starts
none; LANE_WORKTREES_REPORT=off is the switch, and the suite sets it.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Report the estate's leftovers with sweep --all --dry-run --report

Workspace creation outpaces closeout, and nothing counted it. The
report reads every lane's #97 inventory and snapshot (beside each
checkout's parent, where #97 keeps them), the workspace register and
the disk - never the derived index - and lists, as one markdown
document: FOREIGN clones with size, last commit and Lane: owner;
orphaned trees of ENDED lanes; unmerged branches with a missing,
diverged or unpushed upstream; root-main divergence, with Amendment
4's remedy where only handoff/register paths moved; rescue branches
and dirty inventory trees awaiting disposition past aging_days; caches
and sandboxes by size; ignored directories over ignored_report_mb;
evidence-shaped paths; archives past retention (--expire's own table,
now shared as expiry_rows); the workspace's .gitignore and bytecode
history; and `status --all`'s findings, reported as what they are and
never counted as dirt. The head table carries the rescue-branch count
and the sweeps directory's size.

It changes nothing: no fetch, no index refresh, no expiry. --post
writes it to a file or comments it on owner/repo#n through gh.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document the estate report and prevention at creation

The lanes manual gains the report (its sections, --post, the daily run
from lane-start and its switch) and what keeps leftovers from arriving:
the run.sh relocation, one venv per repository outside the estate, one
evidence root per repository under the state directory, and the
workspace's bytecode rules with lanes-edit.sh's refusal and
pathspec-check.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Take Copilot round 1's defects in the sweep

Every one of these let a destructive act rest on a read that was stale,
failed, or answered a different question:

- Merged now needs the PR's base to be the default branch (origin's HEAD,
  else main/master): a merge into a release branch is not a landing. An
  OPEN PR protects its branch before any older merged PR on that branch
  counts, unless the register LANDED that very PR.
- A failed `rev-list` distance is a read error, never "0 ahead", for a
  tree and for --branches' upstream state alike.
- A clone whose git directory linked worktrees share is kept. Removing it
  would take their repository with it.
- A tree that another lane's inventory also names is kept. An inventory
  record that cannot be read refuses the sweep (exit 2) instead of being
  skipped. The lane name goes through `canon-lane` before any path is
  derived from it, and an unreadable alias table refuses.
- Liveness is asked again before the rescue and again just before
  removal, afresh (processes, tmux, the recording session). A scan that
  cannot be made leaves the tree. Scratch is removed only if no writer
  arrived and nothing outside its caches changed while the tar ran. A
  cache is rechecked against its owning tree.
- Caches never come from a FOREIGN tree, a live one, or one whose
  liveness is unknown.
- Branches are deleted at the SHA that was judged: `update-ref -d <old>`
  locally, a --force-with-lease push for the remote. Each failure is
  counted, and the register claims "+ remote" only for a remote delete
  that happened.
- --branches fetches the lane's own checkout too, and a failed fetch
  deletes nothing.
- A stale registration is removed by `git worktree remove <path>` alone,
  never by a repository-wide prune.
- An ignored-file listing that failed stops the act. It is never read as
  "nothing ignored".
- Each invocation gets its own archive directory, created exclusively.
- An origin-less worktree under --bundle --yes is bundled and removed,
  as its dry run says, instead of being read as a failed fetch.
- --expire requires the rescued SHA itself on origin and an archive that
  is whole: a MANIFEST.sha256 listing every file at its digest, and a
  rescues.tsv whose rows parse.

Ten new or extended cases. Each one fails against the previous head and
passes here.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document round 1's rules in the lanes manual

The manual now states what changed: the canonical lane name, refusal on
an unreadable record, contested trees, the single-registration prune,
the linked-worktree clone guard, merged-into-default with open PRs first,
SHA-fenced branch deletes, liveness asked again before removal, scratch
and cache rechecks, the exclusive archive directory, and expiry of whole
archives only.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Say what expiry_rows keeps now that #168 checks whole archives

The merge from #168 brought in archive_ledger and the exact-SHA check.
The report's archive section reads them through expiry_rows, so its
docstring now says it keeps archives that are not whole and those whose
rescue branch moved.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Stop the suite's whole process group when a run ends

Copilot on #169: a TERM to the pytest pid alone leaves whatever it is
waiting on (a shell suite, a git, a fixture's sleep) running, while the
EXIT trap deletes its temp root and releases the mkdir lock around it.
The suite now starts in a process group of its own, with `set -m` on for
that one line only. The INT/TERM handlers and the EXIT trap TERM the
whole group, reap the leader, wait up to ten seconds for running members
and then KILL the group. Running members are counted from `ps`, never
`kill -0`, because an orphan's zombie answers `kill -0` until whoever
adopted it reaps it.

The kill cases now give the stub suite a child of its own and require
it to be gone. Against the previous run.sh that case fails with "the
suite's own child outlived its wrapper".

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Offer and seed site-packages/ with the other bytecode rules

Copilot on #169: the refusal classifies any path under a site-packages
directory, since that is a virtual environment under any name, but
neither the offered lines nor the seeded ones covered it. Following the
offer therefore left the refusal standing. `site-packages/` now joins
BYTECODE_IGNORES and WIP_HYGIENE_LINES. The shell case adds a venv
named env-x: it is refused, `site-packages/` is among the lines offered,
and it is still refused until that line is added.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Report duplicate clones with no named checkout, over one estate

Copilot on #169, two report defects:

- Two clones of one origin, neither named for the repository, were both
  treated as canonical, so the duplicate finding vanished. The checkout
  is now chosen deterministically (the one named for the repository,
  else the shallowest), and every other clone is reported, with a note
  when the choice was ambiguous.
- `status --all` was pointed at the estate only when --estate was given.
  It now always gets the root the report resolved, and lane-start passes
  its own $PROJECTS_ROOT to the daily run.

The report's hygiene check wants site-packages/ too. Each new assertion
fails against the previous head.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Take Copilot round 2's defects in the sweep

Seven of round 2's eight. Each one let an act proceed on an unknown:

- Another lane's inventory record or directory that cannot be read, or
  that names no path, now refuses the sweep. Who else claims a tree is
  read before the fetch, so the refusal writes nothing.
- A submodule's ignored files (an .env) keep the tree. The tree's
  ignored archive covers the superproject only.
- Ignored files are printed before the archive and listed again at
  removal. A change, an addition or a failed listing leaves the tree.
- A prune candidate whose directory reappeared is left for the next
  sweep to classify.
- A pytest sandbox's .lock is read again at the act, and a live pid
  leaves it.
- `tracked()` returns None when a repository's index cannot be read,
  and such a cache is left.
- A manifest entry whose file is gone makes the archive not whole.

The eighth, unreadable /proc entries of same-account processes, is a
design trade and is filed in #170. On Eagle, 9 such processes exist at
any time (6 `timeout`, 3 `ssh-agent`), so reading them as "unknown"
would keep every tree on every sweep.

Four new or extended cases. Each fails against ae17742 and passes here.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse bytecode that is already staged, not only what add would stage

Copilot round 2 on #169: `git add --dry-run` says nothing about a path
the index already holds at those bytes, yet the path-limited commit
still takes it. bytecode_in_pathspec now also reads `git diff --cached
--name-only --diff-filter=d` for the same pathspec. A staged deletion of
bytecode is a cleanup and is let through. The shell case force-stages a
.pyc after the ignore lines are in place: pathspec-check and the commit
path both refuse it, and nothing is committed.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Quote the venv paths in the command --install prints

Copilot round 2 on #169: the command is meant to be pasted into a
shell, and $XDG_CACHE_HOME is the person's to choose, spaces included.
Both paths are now `printf %q`-quoted. The new case installs with a
cache directory containing a space, and shlex reads each path back as
one word.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Report what the report could not read

Copilot round 2 on #169: an unreadable lane snapshot or inventory
record became an empty one, so a lane or tree dropped out of every
section and the report could look clean. Reads now go through _ls and
_record. A failure other than "not there", a tree record naming no
path, or an inventory tree whose git status fails is a row in a new
section, "Records the report could not read". The unused _sidecar
reader is gone.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep AGENTS.md and README.md within their line caps

test_repo_hygiene caps AGENTS.md at 316 lines and README.md at 486, and
both files were exactly at their caps on main. The run.sh paragraph had
taken AGENTS.md to 330 and the venv sentence README.md to 491. The full
text already lives in docs/README-lanes.md, so AGENTS.md now says the
same thing in the three lines the old sentence took, and README.md says
it on the line it extends. No cap is raised.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Assert the flock file only where there is flock

Copilot on #169: macOS has no flock, so the wrapper takes the mkdir lock
and never creates openrepotools-pytest.lock. The relocation case checked
for that file unconditionally and would have failed the macOS job, which
is the landing gate. Where there is no flock, the case now checks
instead that the mkdir lock was released.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Switch --yes off until #170; keep the gate and exit contract honest

The adversarial review of 3c0b188, reproduced with its probes against
the suite's own Estate fixture:

1. --yes and --expire --yes are refused, exit 2, changing nothing,
   unless LANE_WORKTREES_ENABLE_YES=1 is set. #170 lists data-loss paths
   that are still open. The suite's Estate sets the variable; a real
   estate should not until #170 lands. The dry run, --porcelain and
   --report are untouched.
2. B1: a tree that #97's inventory does not name is still the lane's if
   it stands under .lane-worktrees/<lane>/, or if it sits in
   <checkout>/.claude/worktrees on a branch whose own commits carry this
   lane's Lane: trailer. Another lane's claim still wins. Such trees keep
   the quiet-hours liveness rule. A lane whose state is NONE but which
   has trees of its own is refused, exit 2. On Eagle, `sweep
   openRepoTools-3 --dry-run --porcelain` gave exit 0, `summary 0 15 0
   15`. It now gives exit 2, `summary 9 13 4 4`: 9 trees are the lane's,
   and the snapshot is NONE.
3. B2: subprocess output and git metadata files are decoded with
   surrogateescape, and stdout and stderr write with it too, so a
   Latin-1 worktree name is reported rather than raising. An unreadable
   pytest-of-$USER is a kept sandbox row. A catch-all keeps errors that
   no read caught inside the contract: exit 2 on a dry run, and under
   --yes DISPOSITION.md is written first and the exit is 1.
4. B3: every porcelain field escapes backslash, TAB, newline and CR.
   Worktree registrations are read with `git worktree list -z` where git
   has it, so a newline in a path no longer becomes a phantom
   registration.

Five new cases (switch off, ownership plus NONE, escaping, non-UTF-8
path, unreadable sandbox root), each failing against 3c0b188. Two cases
were moved off the lane root to keep testing FOREIGN trees. 40 cases.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document the --yes switch, lane-root ownership and porcelain escaping

The manual now leads the sweep section with the --yes switch and #170.
It also says which unrecorded trees are a lane's and that a NONE snapshot
with trees is refused. The exit contract now covers the escaped fields
and the catch-all.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Run the suite in the foreground when stdin is a terminal

Adversarial review B7: `set -m` puts the suite in a background process
group with the terminal as its stdin. Anything that reads the terminal
(--pdb, breakpoint(), input()) is then stopped by SIGTTIN, and the
wrapper waits on it for ever while holding the workstation flock. With
a terminal on stdin the suite now runs in the foreground, in the
terminal's own process group, and a Ctrl-C reaches all of it from the
terminal. Without a terminal the process-group stop is unchanged.

The new case runs the wrapper on a pseudo-terminal whose stub suite
prints a prompt and reads a line. It passes here. Against the previous
run.sh it hangs and fails, with the transcript showing the prompt and
the typed line that was never read.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Never adopt a standalone clone under the lane's root

5f4bd84 made every unrecorded tree under .lane-worktrees/<lane>/ the
lane's. That included standalone clones, which #162's first protocol
line and the manual keep FOREIGN ("a lane creates worktrees, never
clones"). Copilot flagged it on 296e2f0. Clones are now excluded from
both adoption paths. The ownership case adds a clone under the lane
root and requires it to stay foreign and not retire-counted. On Eagle
none of the lane's seven root trees is a clone, so the dry run is
unchanged there.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fetch the register directly under --yes and refuse when it fails

#170 A1. lanes-edit.sh's log_sync answers 0 on every way it can fail to
fetch, and an inherited LANES_NO_FETCH=1 skips the fetch outright. A lane
rebound on another host after this workstation's last register fetch
therefore read as bound here, and --yes removed its trees (Amendment
18(b): from outside the binding a lane is UNKNOWN, never dead).

Under --yes the sweep now fetches the workspace repository's
origin/<branch> itself, with an explicit refspec, before anything else.
A fetch that fails refuses the run, exit 2, with nothing changed. Every
later helper call reads the ref that fetch left, so an inherited
LANES_NO_FETCH no longer decides. The dry run is unchanged.

The test estate now carries a workspace repository (the fetch's target,
and #170 G6's register) and records its inventory under an earlier
session, as a swept lane's trees are. The new case rebinds the lane on
another host after the last fetch, then runs --yes with origin
unreachable and with LANES_NO_FETCH inherited: both refuse and the tree
stays.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Pin the sweep's fetch refspec so --prune cannot delete local branches

#170 item 6 (Copilot round 3 on #168). With a mirror-style
remote.origin.fetch such as +refs/heads/*:refs/heads/*, the preflight
`git fetch --prune origin` deleted every local branch origin lacks,
before anything was classified or rescued.

The fetch now names its refspec and refmap, as `status --fetch` does:
+refs/heads/*:refs/remotes/origin/*, with --refmap set to the same, so
the configured refspec maps nothing. fetch.pruneTags and the remote's
pruneTags are forced off, so a local tag is never pruned with it.

The case configures the mirror refspec with nothing checked out that
origin has (git refuses to fetch into a checked-out branch, which hides
the prune) and a local-only branch and tag. Both survive --yes.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse when a lane root or a registration list cannot be read

#170 G1 and G5 (Copilot after the cap). Discovery read a lane worktree
root that could not be listed as an absent root, and a failed
`git worktree list` as a repository with no worktrees. With an empty
inventory either one let the dry run exit 0 and clear #163's gate over
trees nobody could see.

Only a root that does not exist is skipped now; any other listing
failure refuses, exit 2. worktree_registrations returns None when both
listings fail. Discovery refuses on it. The prune's after-check, a
branch delete and --branches stop that act. The report records the
checkout as unreadable.

Two cases: a mode-000 lane root with an empty inventory, and a git shim
that fails `worktree list`. Both now exit 2.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Walk every estate directory for dependents; a partial scan deletes nothing

#170 A11. The estate walk entered a repository only through worktree
containers, so it never saw a plain directory inside a repository (it
found none of xFactory/xFactories/* on Eagle). A clone there that
borrowed a lane clone's objects was unseen, and the clone was deleted
from under it. An empty estate root read no dependents at all, and an
unreadable directory read as one with nothing in it.

walk_estate visits every directory but caches, tool environments and
site-packages. It never follows a symlink or enters a .git, and it
collects .lane-state directories for #170 G6. Whatever it, or the
alternates and config reads, could not read makes the dependents scan
partial. A partial scan, a missing estate root, or a tree outside the
estate root keeps a clone and a scratch directory. Both are removed by
deleting a directory, and a dependent nobody saw would lose its objects.
Worktrees are untouched by this: their objects live in the checkout's
repository, which a worktree removal leaves.

Two cases: a --shared dependent under host/vendor/ makes the clone
load-bearing, and a mode-000 directory in the estate keeps it.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read every other lane's control root before granting a tree

#170 G6 (Copilot after the cap). Other lanes' claims were read only
under this lane's control-root parent and the LANES_LANE_STATE_ROOT
override. A lane in a nested estate keeps its .lane-state beside its own
recorded dir, so its claim was missed, the tree was not contested, and
--yes could take it.

The parents read now also include $PROJECTS_ROOT/.lane-state, every
.lane-state the estate walk found, and the parent of every lane's
recorded dir. The recorded dirs come from one `git grep` over the
register's lane logs on origin/<branch>, parsed as lanes-edit.sh's
payload_subfield parses them. A register that cannot be read refuses:
whose claims were missed would be unknown. Lane names compare without
case, as Amendment 15 has them.

The case puts another lane's claim on one of this lane's trees under
<estate>/group/.lane-state, and under a directory outside the estate
that only the register names. Both keep the tree.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep trees whose hidden edits, nested clones, tags or submodule refs would go

Four ways tree_guards let a removal take work no rescue carried:

- #170 A2: a file flagged skip-worktree or assume-unchanged reads clean
  in `git status`, and `git add -A` skips it, so its edit was in no
  rescue. hidden_edits hashes every flagged file present in the tree
  against its index blob, and a difference keeps the tree. A flagged
  file that is absent (a sparse checkout's) is no edit.
- #170 A3: the nested-repository walk passed over venvs and node_modules
  before it looked for a .git, so `pip install -e git+...`'s clone in
  <venv>/src/<pkg> was deleted with the tree. nested_repos skips only
  bytecode caches, and a directory it cannot read keeps the tree.
- #170 item 4: a clone's publication check read branches and the
  stash, not tags. unpublished_ref checks every branch, every tag
  (peeled to its commit) and HEAD against refs/remotes/origin in one
  rev-list. A tag that names no commit cannot be checked, and keeps it.
- #170 item 5: only a submodule's HEAD was checked. A worktree's
  submodules keep their repositories under .git/worktrees/<id>/modules/,
  so their branches, tags and stash went with the tree. Each initialized
  submodule now gets the same check against every remote of it, plus
  its stash. A `git submodule status` that fails keeps the tree; it used
  to skip the submodule checks.

Cases: both flags, a sparse checkout's absent file (still removed), a
clone in .venv/src, a clone's annotated tag on a commit no branch has,
and a submodule's unpublished branch and stash.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Judge an ignored entry by its own name, not an ancestor's

#170 A6. ignored_paths dropped an ignored file when any ancestor was
named like a cache or build output. With --directory an ignored cache or
build directory is listed as itself, so a path beneath a directory named
`build` is an ignored file in a tracked directory. docker/build/prod.env
was deleted unarchived while top.env beside it was archived.

Only the entry's own basename is compared with CACHE_NAMES, VENV_NAMES
and BUILD_NAMES now. An ignored /build/ directory is still build output
and is not archived.

The case puts prod.env in the tracked docker/build/ beside an ignored
/build/ and checks the archive holds prod.env and top.env and nothing
of build/.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Leave other people's open branches alone: rescue, and keep the remote

Two ways --yes acted on a branch someone else's pull request owns:

- #170 A7: push+remove pushed the lane's unreviewed commit onto a
  teammate's OPEN PR branch, because the decision ignored the merge
  state. Where an open PR names the branch, where origin's tip carries
  another lane's Lane: trailer, or where whether a PR names it could not
  be read (gh unavailable), the commits now go to
  rescue/<lane>/<slice>-<UTC> and origin's branch is left as it is.
- #170 A8: a register LANDED line beat gh's live OPEN for the same
  number, so a LANDED #21 typed for #20 deleted the remote branch of the
  still-open #21 and closed it. The tree and its local branch still go
  (the work is on origin), but the remote branch is never deleted while
  gh answers OPEN for it. The same holds under --branches.

Cases: the open PR, another lane's trailer and LANES_NO_GITHUB each send
the commit to a rescue branch with origin's branch unmoved, and a wrong
LANDED number leaves the open PR's branch on origin.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Rescue before a prune, bundle what only a reflog names, self-check each removal

Four #170 items that share one mechanism: before a removal the sweep now
asks git which commits it would take and where each one is kept.

- The loss plan. A worktree's removal takes its git directory: its HEAD
  and that HEAD's reflog, plus the branch and its reflog where the
  branch is deleted after it. A pruned registration takes the same,
  read from its git directory. A clone takes every ref but its
  remote-tracking ones, and every reflog. One rev-list says which of
  those commits no origin ref, surviving branch, tag or stash, push
  seen on origin this run, or bundle head of this run reaches.
- A4: `prune` dropped the only pointer to a detached commit. A
  registration whose git directory names such a commit is now
  rescue+prune: a rescue branch pushed where there is an origin, a
  bundle, then that one registration removed.
- A5: commits only a reflog names (an amend, a reset, checking out away
  from a detached commit) were lost on removal and on delete_local_branch.
  They go to a bundle of their own through temporary
  refs/lane-worktrees/* refs, which are deleted once it is verified.
- Item 8: a bundle that is the only copy of what it holds (bundle+remove
  with no origin, the reflog bundle, the untracked bundle below) gets a
  rescues.tsv row (origin `-`, branch `bundle:<file>`). `--expire` reads
  it as "no other copy" and never expires that archive.
- A9: the WIP rescue pushed every untracked, un-ignored file, a service
  account key included. The pushed WIP commit now carries the tracked
  changes only. A second commit holding the untracked files goes to the
  bundle alone, as the ledger's only copy. --push-untracked pushes them
  as before.

THE SELF-CHECK. Right before each removal (and each prune) the same
question is asked once more. If anything the removal would take is
neither on origin nor in a verified bundle, the removal is refused,
DISPOSITION.md says so, nothing after it is acted on, and the exit is 2.
LANE_WORKTREES_SEAM_NO_LOSS_BUNDLE=1 is the suite's seam: it skips the
reflog bundle so the case can prove the self-check stops the loss.

Cases: a pruned detached commit (rescued, bundled), a reflog-only
commit (bundled, ledger row), the self-check with the seam (exit 2,
both trees left, the refusal in DISPOSITION.md), untracked files with
and without a tracked change (never on origin, in the bundle,
temporary refs gone), --push-untracked, and an origin-less
bundle+remove archive surviving `--expire --yes` at retention 0.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Run the push hooks where git-lfs is configured

#170 A10. `git push --no-verify` skips git-lfs's pre-push hook, which is
what uploads LFS objects. A rescued branch therefore reached origin as
pointers only, and the tree, the one copy of the objects, was then
removed.

Where the repository has any filter.lfs.* setting (the global one
included) the push now runs its hooks. A hook that refuses fails the
push and leaves the tree. Without git-lfs, --no-verify stays, so an
unrelated pre-push hook cannot hold a rescue up.

The case installs a pre-push hook that records itself. It runs with
filter.lfs configured and does not run without it.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep scratch that holds a tree or a repository

#170 item 7 (Copilot round 3 on #168). A scratch directory was archived
and removed whenever it had no .git of its own, so a worktree or clone
nested beneath it went with the rmtree. A FOREIGN or live tree was not
protected either, and nor was an unpushed commit.

A scratch directory is now kept while any tree of the table lies under
it, or any .git does at any depth (bytecode caches aside). A directory
under it that cannot be read keeps it too. The tree table retires its
own children first, and the scratch goes on a later sweep. The check is
made again at the act and again after the tar.

The case nests a clone with an unpushed commit two levels down in a
scratch directory. --include-scratch --yes keeps the scratch and the
commit.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Check --live paths, want the writer count for own trees, stop on NOT WRITTEN

Three gaps in how --yes takes the coordinator's word and the register's:

- #170 B4: a misspelt --live path was accepted in silence, so the
  writer it was meant to protect was not protected. Each --live path
  must now name a tree of the table (it, or a path inside it), and
  `--live none` stands alone. Anything else is usage, exit 64, decided
  before any repository is fetched.
- #170 B5: a tree this session recorded skips the transcript check,
  because the transcript is this one. --live was demanded only when the
  holder read as this session, so with the holder read as none, --yes
  removed trees this session's own writers could be in. Any inventory
  record whose writer is the caller now demands the count too.
- #170 B6: removals went on after a NOTED line failed. The first line
  the register refuses now stops the sweep. Every later tree and item
  is left as the table found it, with the reason, and the exit stays 1.

Cases: a typo'd --live path and `--live none <path>` exit 64 and touch
nothing; a tree recorded by this session refuses without --live and
goes with --live none; with the register refusing, the first tree goes,
the second stays, and exactly one line was attempted.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Count --branches by origin's own ref, never by upstream or an open PR alone

Two ways `--branches --dry-run --porcelain` gave #163's gate the wrong
answer (Copilot after the cap on #168):

- G2: every OPEN PR's branch was left out of the retire count, even a
  lane-owned one holding commits origin lacks, so the gate read 0. The
  delete protection stays. Such a branch is now counted unless
  refs/remotes/origin/<branch> holds its tip, and a read that failed
  counts it too.
- G7: a lane-owned branch was judged by its configured upstream alone.
  A branch made with `checkout -b X origin/main` tracks main, so once
  pushed under its own name it still read "ahead" and the gate read 3.
  Publication is now checked in origin's ref for the branch's own name
  first, and a branch origin holds that way is not listed. Unknown
  states are still counted.

Cases: an unpushed lane branch named by an open PR makes the dry run
exit 3 and survives --yes; a branch made from origin/main and pushed
as itself makes it exit 0.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Remove a tmp.* only on proof, and read hidden processes as unknown

#170 items 1 and 2 (Copilot on #168), with the coordinator's defaults,
which the PR body states for Brett Heap to overrule:

1. --include-sandboxes removed any account-owned tmp.* over an hour old
   with nobody in it, with no proof a suite made it, so a person's
   `mktemp -d` checkout or saved scratch could go. A tmp.* is now
   removed only with a suite's mark whose pid is gone (pytest's .lock,
   tests/run.sh's new .openrepotools-run marker, or run.sh's run-root
   layout of basetemp/ beside tmp/), or when nothing in it has been
   written for aging_days (14). Age is the newest mtime anywhere inside,
   not the top directory's. Any other tmp.* is a `list` row, kept. A
   marker's pid is read again at the act. tests/run.sh writes its pid
   into each run root it makes.
2. The /proc scan read a same-account process whose entries cannot be
   read (not dumpable) as absent, so its tree could be removed under
   it. Such a process is now in ProcScan.unknown and placed by what can
   be read: absolute paths in its command line, and its parent's
   working directory. A tree, scratch directory, cache owner or sandbox
   it is placed in is of unknown liveness and is kept, at the read and
   again at the act. One placed nowhere (ssh-agent re-parented to init:
   three on Eagle today) holds no tree, so a sweep is not frozen for
   ever. One whose status cannot be read at all could be anywhere. A
   process of another account is never a writer here. The human table
   names every unreadable pid in a note. Uid is read from
   /proc/<pid>/status, because a non-dumpable process's /proc entry is
   owned by root.

Cases: four tmp.* directories (unmarked and two hours old: listed and
kept; twenty days old, pytest-marked and run-root-shaped: removed), and
a non-dumpable child standing in a tree, which is kept. The existing
sandbox and report cases now age every file, not only the directory,
and the killed sandbox carries pytest's mark.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document what #170's fixes change in the sweep

The manual's sweep section and --help now say:

- under --yes the register is fetched first, and a failed fetch refuses;
- each repository's fetch pins its refspec;
- other lanes' claims are read wherever #97 keeps them;
- an unreadable lane root, registration list or register refuses;
- --live must name a tree, and a tree this session recorded wants the
  count;
- the new keep rows (hidden edits, nested clones anywhere, clone tags,
  submodule branches, tags and stash, a partial dependents scan) and
  the rescue+prune row;
- untracked files go to the bundle only, unless --push-untracked;
- rescue rather than push onto another's branch, and no remote delete
  while gh answers OPEN;
- the loss plan, the reflog bundle and the self-check, and the stop on
  NOT WRITTEN;
- the LFS hooks, ignored files judged by their own name, scratch kept
  while trees lie under it;
- tmp.* removed only on proof, the bundle: rows that --expire never
  expires, and the exit contract's new 2 and 64.

The --yes switch and its paragraph go in the next commit, with the
switch itself.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hold the existing cases to the register fetch, the untracked bundle and B5

Three of #170's fixes change what older cases see. A proof run of the
sweep module on CI (the workstation's pytest lock never came free)
failed these seven at the previous head:

- The table case asserted that the WIP rescue pushed the untracked
  new.txt. It now asserts new.txt is not on origin, and that the
  ledger's bundle: row for the dirty tree holds it (A9).
- The four refusal cases and the unreadable-claim case snapshot the
  estate around a refused --yes. --yes now fetches the register first
  (A1), which writes FETCH_HEAD in the workspace repository and nothing
  else, so the workspace is left out of those snapshots.
- The unreadable-record case wrote its record as this session's
  writer. B5 then refused first, with its own reason, so the record is
  written by the earlier session the fixture uses everywhere else.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Walk the whole estate only where a removal deletes a directory

Measured on Eagle, the whole-estate walk A11 asked for is 683,360
directories: 541,236 of them in xFactory alone, mostly spec trees. It
took 25 s warm and 397 s cold, where the gate's dry run took 15 s at
45fa45d. #163's gate would pay that at every lane-end, for nothing a
worktree's removal can break: a worktree's objects are its checkout's,
and the removal leaves them.

The whole walk now runs only where a removal deletes a directory: a
clone the table could take, or --include-scratch. Otherwise the
estate's shape walk is enough, as before. Other lanes' claims (G6)
follow #97's own rungs for every lane: the override, the parent of
every lane's recorded dir in the register, and $PROJECTS_ROOT. Each is
read beside this lane's own root and beside every checkout the shape
walk finds. So they no longer depend on the whole walk. A lane cannot
claim a tree without a register row.

The G6 case's nested checkout is now a repository, as a lane's is.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse an inventory row that carries no id

#170 G9 (Copilot after the cap, on 87da332). lane_facts skipped a
`lane-trees` row whose id was empty. A tree such a row names outside the
scanned roots was then never discovered, and the dry run could exit 0
and clear #163's gate.

A nonempty row with no id is now an unreadable record. Discovery
refuses it, exit 2, as it refuses one of an unknown schema.

The case gives the inventory one id-less row naming a tree outside both
roots: exit 2, where 45fa45d exits 0.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fail closed when the bytecode check cannot read the pathspec

#170 G13 (Copilot after the cap, on 45fa45d). bytecode_in_pathspec threw
both git reads' stderr away, and its awk exits 0 on no input. A read
that failed therefore printed nothing: a malformed magic pathspec, or an
index git cannot read, makes both `add --dry-run` and
`diff --cached` exit 128. `pathspec-check` then reported the pathspec
clean, and the commit path let it through.

Each git status is kept now, and the function answers 3 when a read
failed. `add --dry-run`'s exit 1 is not a failure: it means a named
path is ignored, which the add would not stage. `pathspec-check`
refuses with exit 2. The commit path refuses with exit 2 before it
stages anything.

The shell suite's #162 block gains four assertions: `pathspec-check
':(bogus)x'` exits 2 and says the read failed, and a commit of that
pathspec exits 2 and commits nothing. At 45fa45d the first exits 0 and
the commit fails later, at the add, with exit 6.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document G9's id-less row and G13's failed read

The manual's inventory paragraph now names a row with no id among the
unreadable records that refuse a sweep. The pathspec-check usage line,
and the comment above the subcommand, now say exit 2 also covers a read
git could not make.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read a .git file that is no pointer as no repository, not as unreadable

The read-only dry run over this lane's estate on Eagle kept every
--include-scratch directory as "the dependents scan was partial". The
cause was two empty .git files that uv keeps in its caches to stop git
looking upward. git_common_dir answers '' for those as it does for an
unreadable .git, and dependents_map counted both as unread.

A .git file that can be read and is no gitdir pointer is now skipped
as no repository. Only one that cannot be read leaves the scan partial.

The case puts uv's marker in the estate. A clone taken on a word is
still removed: the scan is whole.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Switch --yes back on: remove LANE_WORKTREES_ENABLE_YES

5f4bd84 switched --yes and --expire --yes off until #170's data-loss
paths were closed. Every item #170 lists for --yes now has a case that
fails against 45fa45d and passes here: items 1, 2 and 4-8, A1-A11,
B4-B6, G1, G2, G5-G7, G9 and G13. Before every removal the sweep also
asserts, of git itself, that each commit the removal would take is on
origin, in a ref that stays, or in a bundle this run verified. If not,
it refuses with exit 2 and a DISPOSITION.md line.

The switch, its refusal, the --help paragraph and the manual's
blockquote are gone. The suite's estates no longer set the variable.
The switched-off case is replaced by one that runs --yes and
--expire --yes with no variable in the environment.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse a clone's removal when a reflog directory under it cannot be listed

#174 Copilot round 1 (lane-worktrees `_loss_plan`): the loss plan of a
clone collected its reflogs with `os.walk`, which passes over a directory
it cannot list in silence. A commit only `logs/refs/heads/<b>` named was
then in no plan: no bundle took it, the self-check asked the same blind
question, and the clone was deleted with it.

`reflog_files` walks `<gitdir>/logs` with an error handler: a `logs`
directory that is not there is an empty answer, anything else it cannot
list makes the loss plan unknown, and the clone is left in place.

The case seals a clone's `logs/refs/heads` (mode 000) with an experiment
only that branch reflog names, and wants the clone and the commit kept.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a tree whose submodule names an unpublished commit only in its reflog

#174 Copilot round 1 (lane-worktrees `tree_guards`): a submodule's
experiment, made detached and checked out away from, is in no branch, tag
or stash of the submodule - only its reflog names it. Every submodule guard
passed, and the tree's removal took the submodule's repository, which lives
under the tree's own git directory, with the experiment in it.

The submodule's reflogs are read now (`unpublished_reflog`, through
`reflog_files`, so a directory it cannot list is unknown too): any commit
they name that no remote of the submodule holds keeps the tree, and says so.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Archive every ignored entry selected, whatever its ancestors are named

#174 Copilot round 1 (lane-worktrees `_tar`): #170 A6 selects an ignored
entry by its own name, so `docker/node_modules/prod.env` - an ignored file
in a tracked directory - is selected for the archive. The archive's member
filter then dropped any path with a cache- or venv-named part anywhere in
it, `top.env` beside it kept the archive non-empty, it verified, and the
tree was removed without prod.env.

The filter now judges only the parts of a member's path BENEATH the entry
it was added for, and always carries the entry itself; and the archive is
read back for every entry selected, so one missing leaves the tree.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Never remove a cache directory that holds a repository

#174 Copilot round 1 (lane-worktrees `_caches`): a tree kept because a
clone is nested in its venv (`pip install -e git+...` clones into
`<venv>/src/<pkg>`, #170 A3) still gave up its caches, and `.venv` is one.
`--include-caches --yes` removed it, and the clone and its unpushed fix
with it.

A cache candidate that is a repository, holds one anywhere inside, or
holds a directory that cannot be read is now a `keep` row when the table is
built, and asked again right before the removal. The venv case of A3 runs
`--include-caches --yes` too and wants the clone's commit kept.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* List an unmarked tmp.* that holds a repository, whatever its age

#174 (found auditing that a check guards every removal): `--include-
sandboxes` removed an unmarked `tmp.*` untouched for `aging_days` on age
alone - the second half of #170 item 1's default - so a person's `mktemp -d`
checkout with an unpushed commit in it went. No suite's mark says such a
directory's repositories are fixtures, so one that holds a repository, or
a directory that cannot be read, is listed and never removed; the check is
asked again right before the removal. A marked one is still the suite's,
and its repositories are its fixtures.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Bundle a branch's reflog-only commits and self-check before --branches deletes it

#174 Copilot round 1 (lane-worktrees `_act_item`): the loss plan, the
bundle and the self-check guarded a tree's branch delete only. With
`--branches --yes` a merged branch no worktree holds was deleted with
`update-ref -d`, which takes its reflog too - the last pointer to an
experiment committed on it and reset away from.

A branch item now asks what the delete would take (its tip and every
commit its reflog names, against origin, the other branches, tags, the
stash, this run's pushes and bundles), bundles what nothing keeps, with a
`bundle:` ledger row, and asks once more right before the delete. A refusal
there stops the sweep, exit 2, and DISPOSITION.md says so, as it does for a
tree; a refused item is no longer counted in the register's "swept branches"
line. The bundle helpers take a repository and a name instead of a tree.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hold back untracked files by the act's own snapshots, and keep untracked git-lfs content

Two #174 Copilot round 1 findings in the WIP rescue (lane-worktrees
`_wip_rescue`), both about what goes to a bundle only (#170 A9).

Whether untracked files were held back was read from the table's status,
taken minutes earlier. A tree with only a tracked change when it was read,
and an untracked key file by the time of the rescue, had its full
snapshot - key included - pushed to origin. The pushed snapshot is now
always the tracked-only one (unless `--push-untracked`), and what the full
snapshot holds beyond it is what goes to the bundle alone.

An untracked file git-lfs filters is snapshotted as a POINTER: its bytes
go to the local LFS store, which no bundle carries and the removal may
take, so the bundle that was its only copy held no payload. Where git-lfs
is configured, such a tree is now `keep` - in the table, and again at the
act before anything is pushed (a `_Keep` found at the act leaves the tree
as the table would have).

The cases make the file arrive between the table and the act with a `git`
that writes it on the hidden-edit guard's second call.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read a clone's annotated tag through its peeled commit, and bundle the tag object only origin lacks

#174 Copilot round 1 (lane-worktrees `lost_tips`, `_loss_plan`): a
clone's loss plan read every ref's raw `%(objectname)`, so an annotated
tag arrived as its tag object. `lost_tips` reported any object that was no
commit as lost whatever kept it - so the tag was bundled, then the
self-check reported it lost again, refused the removal and halted the
sweep, for every clone with an annotated tag.

A tag object is now kept where a SHA in the keep list names it - a
verified bundle's head, or origin's own copy of that very tag object,
read with `ls-remote --tags` (none when origin cannot be asked, so the tag
is bundled) - and its history is its peeled commit's, asked like any other
commit. A SHA in the keep list counts through the commit it peels to.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document what #174's first Copilot round changes in the sweep

The manual's table and the command's own help now say that a submodule's
reflog-only commit keeps its tree, that untracked git-lfs content keeps
its tree and untracked files are read from the act's own snapshots, that
a clone's reflog directory that cannot be listed keeps the clone and its
annotated tags are bundled where origin lacks that very tag object, that
the ignored archive carries every entry selected whatever its ancestors
are named, that a `--branches` delete has its own loss plan, bundle and
self-check, and that neither a cache nor an unmarked `tmp.*` sandbox that
holds a repository is ever removed.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* lane-end now refuses to end a lane while #162's sweep still finds its worktrees, branches, scratch, caches or residue on disk (exit 2, naming each and the exact sweep command; a gate that cannot be read is exit 1), with --inventory-only as the one door past it that writes its reason into the row and the ENDED line, and lane-worktrees add makes a lane's worktree and records it in the lane's #97 inventory in the same act, because a lane's inventory was written only at a handoff and lane openRepoTools-3's was empty while nine trees under its root were its own.

The gate reads `lane-worktrees sweep <lane> --branches --include-scratch
--include-caches --dry-run --porcelain` and refuses on 3 and 2, and beside
it reads every tree of the lane's with `git status --porcelain --ignored`
and every entry directly under `.lane-worktrees/<lane>/` that no row names,
so ignored residue counts. `--sweep` is refused by name until #170 switches
`--yes` back on. `--branches` no longer reads a lane directory that is no
repository as a branch read that failed, which held the gate shut for every
lane started outside a checkout.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* lane-worktrees keeps its add verb under its own header ahead of the main section, so the file still reads as utilities, sweep, report, add, then main.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a tree whose git directory keeps a submodule repository with work no remote of it holds, a deinitialized one's included

#174 Copilot round 2 (lane-worktrees `tree_guards`): the submodule
guards read only what `git submodule status` lists as checked out. A
DEINITIALIZED submodule shows `-`, its working tree is gone, and its
repository - branches, a stash, reflog entries - stays under
`<gitdir>/modules/<name>`, which the tree's removal takes. A branch no
remote held went with the tree.

Every repository kept under the tree's git directory's `modules/`
(nested ones included) is now read: a branch, tag or HEAD no remote of it
holds, a stash (read as its ref, since `git stash list` wants a working
tree) or a reflog entry no remote holds keeps the tree, and so does a
`modules/` directory that cannot be read.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a tree whose submodule has a skip-worktree or assume-unchanged edit that git status hides

#174 Copilot round 2 (lane-worktrees `tree_guards`): #170 A2's
hidden-edit guard read the superproject only. A file flagged
skip-worktree or assume-unchanged in a submodule and edited there reads
as clean in that submodule exactly as in the superproject, no rescue
carries it, and it went with the tree. The same `hidden_edits` read now
runs in every checked-out submodule, and flags that cannot be read keep
the tree too.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Pass over nothing but .git when proving a removal takes no nested repository

#174 Copilot round 2 (lane-worktrees `nested_repos`): the walk skipped
directories named `__pycache__`, `.pytest_cache`, `.mypy_cache` and
`.ruff_cache`. It is the proof that a removal takes no repository, so a
checkout under a directory with one of those names was missed: a scratch
holding one was archived without it (the archive leaves caches out) and
removed. NESTED_SKIP is gone and every directory but `.git` is entered.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read another lane's claims in every .lane-state the whole-estate walk finds

#174 Copilot round 2 (lane-worktrees `Sweep.gather`): where the whole
estate is walked - a clone the table could take, or `--include-scratch` -
`walk_estate` finds every `.lane-state`, one in a plain directory inside
a repository included (`host/vendor/.lane-state`), but the sweep threw
that list away and read claims only beside the shape walk's checkouts and
the register's recorded directories. With no log naming that place, a
clone another lane claimed from there was removed. The walk's control
roots are now read for claims as well, before liveness and the table.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Never push a lane's commits onto a branch gh still answers OPEN, whatever a LANDED line says

#174 Copilot round 2 (lane-worktrees `MergeEvidence.judge`, `_decide`):
a register LANDED line naming a pull request gh still answered OPEN, with
the local tip ahead of the PR's head, made the verdict "moved". That
verdict dropped gh's OPEN answer, so #170 A7's rule never fired and
push+remove put the lane's unreviewed commits onto the open pull
request's branch. The moved and merged-elsewhere verdicts now carry gh's
OPEN numbers, and a branch gh answers OPEN goes to a rescue branch, with
origin's branch left as it is.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Never prune a gone worktree's registration whose git directory keeps a submodule repository with work

#174 Copilot round 2 (lane-worktrees `_decide`, `_act_prune`): a
registration whose directory is gone still keeps its submodules'
repositories under `<admin>/modules/`, and the prune - which read only
the superproject's HEAD and reflog - removed them, a branch no remote held
with them. The same submodule-repository read as the tree guard now keeps
such a registration (`keep`), and is asked again right before the prune.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a clone with a local git-lfs store when its commits would go to a bundle only

#174 Copilot round 2 (lane-worktrees `_act_tree`): a clean clone with a
commit only its reflog named had that commit bundled and was removed with
`.git/lfs/objects`. A bundle carries LFS pointers, never their bytes, and
no push uploaded them, so the bytes behind those pointers were lost. A
CLONE whose local LFS store holds anything (or cannot be read) is now kept
wherever a bundle would be the only copy of a commit - the `bundle+remove`
disposition and the reflog-only commits of step 2b alike.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Count a suite's .lock or run.sh marker as provenance only when it names a pid

#174 Copilot round 2 (lane-worktrees `sandbox_provenance`): an EMPTY or
malformed `.lock` (or `.openrepotools-run`) counted as a suite's mark,
so a person's two-hour-old `tmp.*` with a checkout in it was removed -
past coordinator default 1's 14-day rule and the repository guard both. A
mark now proves a suite made the directory only when it names a pid; the
run-root layout proof (`basetemp/` beside `tmp/`) is unchanged.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document what #174's second Copilot round changes in the sweep

The manual's table and the command's own help now say that every
submodule repository a tree's or a gone registration's git directory keeps
- a deinitialized one's included - keeps it where it holds work no remote
of it holds, that a submodule's hidden edits keep the tree, that a
repository under a cache-named directory is still found, that the
whole-estate walk's `.lane-state` finds are read for claims, that gh's
OPEN wins over a LANDED line, that a clone's local git-lfs store keeps it
where a bundle would be a commit's only copy, and that a suite's mark must
name a pid.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read a submodule repository kept under a git directory as that git directory alone, so a gone worktree's is read rather than refused

A submodule repository under `<gitdir>/modules/` names its working tree in
`core.worktree`. Where that directory is gone - every registration whose
worktree directory went, and a submodule removed after its deinit - every
git command run in the repository dies on "cannot chdir", so the guards
of this round's first and sixth commits read every such repository as
unreadable: a gone worktree with any submodule was kept for that reason
alone, never read. The proof run on the round's head showed it (the
prune's reason said the branches "could not be read" where the test
expected the unpublished branch named).

The submodule-repository reads now run each git command there with the
repository's own `objects/` as its working tree (`GIT_…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants