Repository navigation
lane-worktrees: close #170's --yes data-loss and gate holes, self-check every removal, switch --yes back on - #174
Merged
Merged
Conversation
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>
# Conflicts: # lane-worktrees
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>
Contributor
Author
|
@codex review |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
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>
This was referenced Oct 6, 2026
…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>
…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>
|
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
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_…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



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 switcheslane-worktrees sweep --yesand--expire --yesback 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_YESappears 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").
--yeshas been refused since 5f4bd84 unlessLANE_WORKTREES_ENABLE_YES=1was set. The reviews of #168 and #169 found these data-loss paths:--prune;--no-verifyskipping the git-lfs upload;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
--branchescounts 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:
logs/HEAD, plus the branch reflog when the branch is deleted;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 aDISPOSITION.mdline. 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:
--branchesdelete (its tip and its reflog): the commit-level self-check above;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);.lock, run.sh's marker or layout, with its pid gone) is removed on that mark. Its repositories are the suite's fixtures;--expireremoves only an archive whose ledger is whole and whose every rescue is on origin at its SHA, and never one with abundle:row;The two defaults applied (stated for Brett Heap to overrule)
Item 1,
/tmp/tmp.*sandboxes. Atmp.*directory is removed only on proof, in one of two forms:.lockor the.openrepotools-runmarkertests/run.shnow writes, each naming a pid (since 3cdd387 an empty or malformed one is no mark), or run.sh's run-root layout (basetemp/besidetmp/);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 unmarkedtmp.*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 -ddirectory records no pid, so I read its second half as "unmarked and untouched for 14 days". To overrule: the stricter reading never removes an unmarkedtmp.*. That is a one-line change insandbox_items.Item 2, unreadable
/procentries. 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--yestakes it with no other tree. "Could be in" means one of three things:statuscannot be read either, in which case it could be in any tree.On Eagle the always-present unreadable ones are 3
ssh-agentprocesses, 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 anoteline.Items, commits and tests
All tests are in
tests/test_lane_worktrees.pyunless noted. "Fails at base" means the case ran against 45fa45d'slane-worktrees(andlanes-edit.sh) with this branch's tests, in the CI proof runs below.tmp.*only on prooftest_a_tmp_dir_with_no_mark_of_a_suite_is_listed_until_it_is_old/proctest_an_unreadable_process_of_this_account_keeps_the_tree_it_namestest_a_clones_tag_only_commits_keep_ittest_a_submodules_unpublished_branch_or_stash_keeps_its_tree[branch,stash]--prunetest_a_mirror_style_fetch_refspec_prunes_no_local_branchtest_scratch_holding_a_repository_is_kepttest_an_archive_whose_bundle_is_the_only_copy_never_expires--yestest_yes_reads_the_register_now_or_refuses[origin-unreachable,LANES_NO_FETCH-inherited]test_an_edit_git_status_hides_keeps_the_tree[--skip-worktree,--assume-unchanged]test_a_sparse_checkouts_absent_files_are_no_edittest_a_clone_inside_a_venv_keeps_its_treetest_a_pruned_registrations_detached_commit_is_rescued_firsttest_a_commit_only_the_reflog_names_is_bundled_before_removaltest_an_ignored_file_beneath_a_build_named_directory_is_archivedtest_unreviewed_commits_never_go_onto_somebody_elses_branch[open-pr,another-lanes-trailer,gh-unreadable]test_a_landed_line_never_deletes_a_branch_gh_answers_opentest_untracked_files_go_to_the_bundle_never_to_origin[True,False],test_push_untracked_pushes_them--no-verifyvs git-lfstest_a_push_runs_the_hooks_where_git_lfs_is_configured[True]([False]is its control)[True])test_a_dependent_anywhere_in_the_estate_makes_a_clone_load_bearing,test_a_partial_dependents_scan_removes_no_clone.gitmarker filetest_a_dot_git_file_that_is_no_pointer_leaves_the_scan_whole--live→ 64test_a_live_path_that_names_no_tree_is_a_usage_errortest_trees_this_session_recorded_want_the_writer_counttest_the_first_register_line_refused_stops_the_sweeptest_a_lane_root_that_cannot_be_listed_refuses_the_dry_runtest_registrations_that_cannot_be_read_refuse_the_dry_runtest_another_lanes_claim_is_read_wherever_its_control_root_is[nested-in-the-estate,recorded-in-the-register]test_a_lane_owned_open_pr_branch_origin_lacks_is_still_countedtest_a_branch_published_under_its_own_name_is_not_unfinishedtest_an_inventory_row_with_no_id_refuses_the_dry_runpathspec-checkfails closedtests/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 nothingtest_the_self_check_refuses_a_removal_that_would_lose_a_commit--yesswitched back ontest_yes_needs_no_enabling_variableSome commits only support the items above:
docs/README-lanes.md).--yes, untracked files to the bundle, and B5 for a record this session wrote.--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(andlanes-edit.sh) and this branch's tests, in proof run 37500368196 (below).tests/test_lane_worktrees.py)tree_guardstest_a_submodule_commit_only_its_reflog_names_keeps_the_tree_tartest_an_ignored_file_beneath_a_cache_or_venv_named_directory_is_archived[node_modules,.venv]_caches--include-cachesremoved a.venvholding a clone (pip install -e git+)test_a_clone_inside_a_venv_keeps_its_tree(now also--include-caches --yes)_loss_plantest_a_clones_reflogs_that_cannot_be_listed_remove_nothing_act_item--branches --yesdeleted a branch, and its reflog, with no loss plan, bundle or self-checktest_a_branch_deletes_reflog_only_commit_is_bundled_first,test_the_self_check_refuses_a_branch_delete_that_would_lose_a_commit_wip_rescuetest_an_untracked_file_that_arrives_after_the_table_is_never_pushedlost_tipstest_a_clones_annotated_tag_neither_halts_the_sweep_nor_is_lost[only-in-the-clone,on-origin-too]only-in-the-cloneyes (the tag's message was lost);on-origin-toopasses there, as it should: the halt came with this PR's self-check_wip_rescuetest_untracked_git_lfs_content_keeps_the_tree[seen-by-the-table,arrived-after-it]sandbox_itemstmp.*aged past 14 days was removed with a repository and its unpushed commit insidetest_an_old_unmarked_tmp_dir_holding_a_repository_is_listed_never_removed685cbe3 is the manual and
--helpfor all of these. Fix sketches:ls-remote --tags), else bundled with its message;--push-untracked, and what the full snapshot holds beyond it goes to the bundle;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-worktreesat 685cbe3 (the head before the round) and this branch's tests, in proof run 37506230403.tests/test_lane_worktrees.py)tree_guards<gitdir>/modules/with a branch no remote held, went with the treetest_a_deinitialized_submodules_kept_repository_keeps_the_treetree_guardstest_a_submodules_hidden_edit_keeps_the_treenested_repostest_a_repository_under_a_cache_named_directory_keeps_the_scratchgather.lane-statefinds were thrown away, so a claim inside a repository was missedtest_another_lanes_claim_inside_a_repository_is_read_before_a_clone_goesjudge,_decidetest_a_landed_line_never_lets_commits_onto_a_branch_gh_answers_open_act_prune<admin>/modules/) were pruned unreadtest_a_gone_worktrees_kept_submodule_repository_is_never_pruned_act_treetest_a_clones_local_lfs_store_keeps_it_when_a_bundle_would_be_the_only_copysandbox_provenance.lockcounted as a suite's marktest_a_suite_mark_that_names_no_pid_is_no_proof[.lock,.openrepotools-run]module_tags_whytest_a_submodules_annotated_tag_object_is_never_lost_with_its_tree[only-in-the-submodule,on-its-remote-too]only-in-the-submoduleyes;on-its-remote-toopasses, as the controlwalk_estatesite-packagesNotes on the round:
core.worktreeis 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 ownobjects/as the working tree.--helpfor the round.remove;--yeskeeps the tree.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--yeson 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.--dry-run --porcelain)--include-foreign --branches --include-scratch --include-cachesreview/*branches are published under their own names, G7); 27 cache rows--include-sandboxespytest-of-brettwith dead.lockpids)Notes on the "after" column:
dash-scratchandfeatlist-scratchare now kept, because repositories lie under them (item 7).dash-scratch/bitehas no commits and a staged.gitattributes. The table's earlier archive would have swallowed it.__pycache__and.pytest_cachedirectories inside those two kept scratch directories. Before, they rode inside the scratch archive.--include-scratchturns it on.ssh-agentpids (item 2).git status --porcelainempty).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 temporaryproof/openRepoTools-3/*branches and dispatched a trimmedtests.ymlon Linux. They are deleted now.Head, full Linux suite:
macos_job), 31 min;Base: this branch's tests ran against 45fa45d's
lane-worktrees:lanes-edit.shtoo: the shell suite failed only on G13's 4 assertions, andtest_yes_needs_no_enabling_variablewas refused by the switch.--yescase 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.test_a_sparse_checkouts_absent_files_are_no_editand...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, andtests/run.shon 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--yesswitch set in the fixture: 12 failed, 1 passed (theon-origin-tootag case, as explained above).Round 2 (lane openRepoTools-1). These used temporary branches
proof/openRepoTools-1/174-round2,-round2band-round2c, each deleted once read:core.worktreeread failure that c538ec1 fixes;head(-k "lane_worktrees or repo_hygiene or openrepotools_command"): 346 passed and 1 failed. The failure istest_the_macos_job_parses_every_bash_file_this_repository_ships, which reads the trimmed proof workflow itself.red-685cbe3: 10 failed and 1 passed (theon-its-remote-toocontrol).This PR's own CI (Linux and the
ready-gated macOS job) is the landing record. I have not appliedready.Not in this PR
#179 (PR 2), stacked on this one, takes the honesty, portability and test items and says
Closes #170: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
--yesoperations.New Features:
--yesexecution for worktree sweeping and archive expiration without an environment opt-in.Bug Fixes:
Enhancements:
Documentation:
Tests: