Skip to content

lane-end's close-out gate: refuse while the sweep still finds the lane's trees, branches, scratch, caches or residue, --inventory-only as the logged door, and lane-worktrees add records every tree it makes (#163) - #175

Merged
brettheap merged 91 commits into
mainfrom
feat/lane-end-closeout
Oct 7, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

What

The close-out gate of #163, stacked on #169's branch (feat/lane-worktrees-report) because it reads #168's exit contract. Lane 3 retargets it to main once #169 lands.

  1. lane-end <lane> asks lane-worktrees sweep: one act that archives, rescues and removes a lane's abandoned worktrees, branches, scratch and killed-suite sandboxes after an ungraceful swap — dry run first, rescue branch or bundle before any removal, never a live writer #162's sweep before it ends a lane, and refuses while anything of the lane's is still on disk. After the in-flight guard and before any write (on --dry-run too), it runs
    lane-worktrees sweep <lane> --branches --include-scratch --include-caches --dry-run --porcelain
    and reads lane-worktrees sweep: retire a lane's abandoned worktrees, rescue first, never a live writer (#162, part 1 of 2) #168's contract:

  2. Two reads of the gate's own, beside the table:

  3. --inventory-only is the one door past the gate. The lane ends whatever the gate answered (found, refused, unreadable). Nothing on disk is touched, and the trees stay in the lane's Crash-consistent lane worktree recovery: a lane now says WHERE its session stopped — RUNNING → SWAPPING → SWAPPED with a generation/operation fence, a machine-readable worktree inventory taken at every handoff, and a resume reconciliation that reports and resets nothing #97 inventory, where lane-worktrees: the daily estate report, and nothing new lands beside the code (#162, part 2) #169's daily report lists them as an ended lane's. The reason it was let past is logged in two places:

    • the row's closing phrase gains ENDED WITH --inventory-only: <what the gate found>; they stay in its #97 inventory;
    • the lane's own ENDED/RETIRED line carries inventory-only: the close-out gate (sweep exit 3) found 2 tree(s) and 1 scratch dir(s) left; lane-worktrees sweep <lane> … names them.

    It does not pass the in-flight guard (that is --force), and when the gate passes it changes nothing.

  4. Creation registers the tree: lane-worktrees add <lane> <slice> [--branch <b>] [--from <ref>] [--checkout <dir>] [--dry-run].

    • It makes <parent of the lane's dir>/.lane-worktrees/<lane>/<slice> as a git worktree of the lane's checkout, then records it with lanes-edit.sh set-lane-tree in the same act.
    • A new branch is made --no-track, so a bare git push never aims a writer's commits at the branch it was cut from.
    • A record that fails after the tree is made takes the fresh tree and its new branch back out (exit 2). Only if that undo also fails is the exit 1, with the record command printed.

    The lane tooling performed no git worktree add before this. lane-start makes no writer worktrees: grep -n 'worktree add' lane-start lane-end lane lane-handoff lanes-edit.sh matches only comments. Every writer's tree was a git worktree add typed into a brief, and the inventory was written only at a handoff. That is how lane 3's inventory was empty while nine trees under its root were its own. A tree made any other way (a brief's hand git worktree add, the harness's isolation: worktree) stays FOREIGN until lanes-edit.sh set-lane-tree <lane> <path> records it. That existing verb is the "adopt" of lane-end close-out gate: refuse to end a lane whose inventory still has worktrees, branches, scratch or caches — the dry-run sweep first, lane-end --sweep --yes to retire them in the same run; landing retires its own tree #163's fold, and no second verb was added for it.

  5. One fix in lane-worktrees sweep: retire a lane's abandoned worktrees, rescue first, never a live writer (#162, part 1 of 2) #168's sweep that the gate needed. --branches read a lane directory that is no repository as a branch read that failed (a keep row, retire), which would have held the gate shut for every lane started outside a checkout. _branch_checkouts now skips a directory git says is no repository. A repository that cannot be read is still a keep. Found by the suite's own repoQ-1, whose recorded dir is a plain directory.

Deliberately left out

A consequence the lane owner should know

The sweep refuses (2) while any other lane's inventory record under the same control root cannot be read (#168's rule). So a broken sidecar anywhere in an estate's .lane-state now refuses every lane-end there until it is fixed or --inventory-only is the word. The suite's #91 section leaves such dangling sidecars on purpose. For that reason:

  • this PR's own section builds its estate beside $HOME/projects and exports its own LANES_LANE_STATE_ROOT (put back at its end);
  • the one existing case it reached, the workstation-seam lane-end repoWS-1 --force, now passes --inventory-only beside --force, with a comment saying why. Its assertions about the host name are unchanged.

Tests

tests/test_lane_helpers.sh, new section == opensoft/openRepoTools#163 …, with its own repository, bare origin, lanes repoCG-*, .lane-worktrees roots and control root under the suite's sandbox. The suite now copies lane-worktrees into its sandbox bin beside the other commands.

case holds
add --dry-run prints the path, makes nothing
add a worktree of the lane's checkout, on the slice's branch, recorded in lane-trees (path, branch, checkout)
add --branch --from origin/main the branch tracks nothing
add refusals existing path (names set-lane-tree), lane with no directory, bad slice (64), a sweep flag (64)
add with the record failing (read-only trees/) refused, the fresh tree and the new branch are gone
lane-end passes a lane with nothing on disk, next to another lane's recorded trees in the same checkout
lane-end passes a lane started outside any repository (the --branches fix)
lane-end refuses on 3 names both trees, the .env via git status --ignored, the untracked file, the scratch, the table, the --yes and #170, the door; row and log unchanged; also on --dry-run
--sweep refused by name
--inventory-only past 3 the row says ENDED WITH --inventory-only and sweep exit 3, the ENDED line logs the reason, the tree and its inventory record are untouched
sweep refuses (2) a hand-made tree under the lane root with no #97 snapshot: refused, relaying the sweep's reason; no --yes offered
residue a plain directory under the lane root: refused at sweep 0, named, then ends once it is gone
cache node_modules under the lane root: refused at sweep 0
branch an unpublished local branch with this lane's Lane: trailer, held by no tree: refused, and list is said to be a person's
unreadable gate a sweep that exits 7, and no sweep at all: exit 1, nothing written; --inventory-only ends it and the row says so
the door's scope --inventory-only with a fork's --retire <pid> and with --retire-dormant: refused by name
round 1: add whose git worktree add fails after making the tree a failing post-checkout hook: the registered tree and the new branch are taken back out, exit 2
round 1: unlistable lane root mode 000: the gate is unread, exit 1, nothing written
round 2: the hook commits, then fails the clean tree goes, the branch is KEPT holding the hook's commit, exit 1
round 2: unlistable root and the sweep answering 3 still exit 1, and the branch is still named

Red at the base head (45fa45d): the section run against the base's own lane-end/lane-worktrees (git archive of 45fa45d plus this test file): 25 passed, 72 failed of the section's 97 assertions (the 4 footer guards among the 25). Every case above has at least one assertion that fails there. Two guard cases pass there by design because the base has no gate to refuse wrongly: "a lane with nothing of its own on disk ends" and "a lane started outside any repository ends". The second is red against this head without the _branch_checkouts fix (the suite's repoQ-1 went red that way).

Local runs (Eagle):

  • this section at the head (ed36551): 97 passed, 0 failed;
  • the suite prefix through Addendum 5: 574 passed, 0 failed;
  • the suite prefix through Amendment 15 (which holds the other ending cases, repoZG-1 and repocase-1): 2060 passed, 0 failed;
  • the Amendment 13 section on its own: 123 passed;
  • test_repo_hygiene.py 152/152, test_lane_worktrees.py 40/40 and the installer help tests 6/6, through a direct harness that calls the test functions, because tests/run.sh waits behind other estates' pytest runs on this workstation.

CI is the record. At ed36551 (and before it at 46f1178) the whole suite is green:

  • tests: 1159 passed (40 min);
  • tests-no-submodule: 947 passed, 212 skipped;
  • tests-windows: 153 passed;
  • parse-macos and guard-launch-mode: pass.

tests-macos waits for the ready label.

Review

Part of #163 (the gate half; what remains of #163 is #181)

Refs #162 #168 #169 #170 #97

Lane: openRepoTools-1

🤖 Generated with Claude Code

Summary by Sourcery

Enforce lane close-out cleanliness and provide an inventory-preserving override while ensuring newly created worktrees are recorded and safely rolled back on failure.

New Features:

  • Add lane-worktrees add to create lane worktrees and record them in the lane inventory atomically.
  • Add lane-end --inventory-only to close lanes without removing on-disk work, while recording the gate result and preserving inventory records.

Bug Fixes:

  • Prevent lane close-out from passing when lane-owned trees, branches, scratch, caches, residue, or unreadable state remain.
  • Treat unreadable repository metadata as a sweep failure while allowing directories with no repository to pass as absent.
  • Clean up worktrees and newly created branches when worktree creation or inventory recording fails, while preserving branches containing new commits.

Enhancements:

  • Make lane-end run the worktree sweep and local status/residue checks before writing any ending state, including during dry runs.
  • Document the close-out gate, inventory-only behavior, and worktree creation workflow.
  • Expand lane and worktree test coverage for gate outcomes, cleanup, unreadable state, residue, caches, and inventory preservation.

Documentation:

  • Document the lane close-out gate, --inventory-only, and lane-worktrees add usage and behavior.

Tests:

  • Add shell coverage for worktree creation, inventory recording, close-out refusals, override behavior, residue, caches, branches, unreadable gates, and rollback scenarios.
  • Add Python coverage for distinguishing unreadable repositories from directories without repositories.

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

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

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

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

Part of #162.

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

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

Part of #162.

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

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

Part of #162.

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

Part of #162.

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

Part of #162.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Copy link
Copy Markdown
Contributor Author

Results promised in the body:

  • The suite prefix through Amendment 15, run locally on Eagle at 10b67c6's scripts: 2060 passed, 0 failed. It holds the other ending cases, including repoZG-1 --force and repocase-1 --force.

  • CI at 46f1178, the whole suite:

    • tests: 1159 passed (41 min);
    • tests-no-submodule: 947 passed, 212 skipped;
    • tests-windows: 153 passed, 1006 skipped;
    • parse-macos and guard-launch-mode: pass.

    tests-macos waits for the ready label.

  • Round 1 (Copilot, 2 threads): both fixed in 41b5a6a, each with a shell-suite case that is red at 46f1178. The section is now 91 passed locally.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Rollback can discard commits, and the gate can miss or misclassify lane-owned leftovers.

Review effort: Balanced
Findings: 2 High severity · 1 Medium severity

Open (3)
Resolved since last review (2)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Malformed Git metadata is misclassified as no repository

lane-worktrees:2636

Matching this stderr phrase also classifies a malformed or incomplete .git directory as “no repository”: Git reports “not a git repository” for that state too. The branch read is then skipped and lane-end can pass instead of emitting the required keep row for a repository that could not be read. Distinguish absence of repository metadata from present-but-invalid metadata before returning true.

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

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

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

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

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

brettheap and others added 2 commits October 6, 2026 20:46
…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>
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 #175 did not itself change it; the result differs from main by
exactly #175's own diff.

Lane: openRepoTools-1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap and others added 6 commits October 6, 2026 22:33
… the entry it names in the residue read, so a standalone clone with an unpushed commit under the lane's own root refuses the lane's end instead of passing as nothing left on disk (finding S1 of lane 3's review of PR #175).

The tree row joined gate_named before the `foreign) continue`, so a clone
under .lane-worktrees/<lane>/, which the sweep keeps FOREIGN and which
counted for nobody, was named for the residue read as well: lane-end exited
0 and the row said NOTHING IN FLIGHT.

Of the review's two options this leaves the FOREIGN row out of gate_named
rather than counting it, because that hands the clone to the gate's own
listing of the lane's root, which already refuses anything no counted row
accounts for and reads an unlistable root as exit 1, with no new path match
whose miss would pass it again. Counting the row instead would have needed
the lane's root before the sweep's rows are read and a prefix match of the
sweep's path spelling against it, and the residue read would still have had
to stop taking a FOREIGN row as a name for the fix to hold.

One new shell-suite case in the #163 section (lane repoCG-13), red at
78492a4: lane-end exited 0 and wrote NOTHING IN FLIGHT. The manual's
sentence on FOREIGN rows in the gate says the same.

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 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>
…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>
…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 and others added 2 commits October 7, 2026 01:08
…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>
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>
@sonarqubecloud

sonarqubecloud Bot commented Oct 7, 2026

Copy link
Copy Markdown

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

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants