Skip to content

lane-worktrees sweep: retire a lane's abandoned worktrees, rescue first, never a live writer (#162, part 1 of 2) - #168

Merged
brettheap merged 13 commits into
mainfrom
feat/lane-worktrees-sweep
Oct 6, 2026
Merged

brettheap merged 13 commits into
mainfrom
feat/lane-worktrees-sweep

Conversation

@brettheap

@brettheap brettheap commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

What

lane-worktrees sweep <lane> — the one act that retires a lane's abandoned worktrees, branches, scratch and killed-suite sandboxes after an ungraceful swap. Dry run by default; --yes performs it. One rule throughout: nothing is deleted that is not first on origin or in a bundle under ${XDG_STATE_HOME:-$HOME/.local/state}/openRepoTools/sweeps/<lane>/<UTC>/, and a tree a live writer owns is never touched. Placed by openRepoTools --install as the seventeenth file (31 artifacts).

This is the first of two stacked PRs for #162 (the core). The second — tests/run.sh's cache/temp relocation, the per-repository venv, the estate-wide --all --report run by lane-start, the <user>-wip .gitignore and the bytecode refusal — is stacked on this branch.

Why

A swap that dies on a usage limit, a killed pane or a crashed harness leaves its writers' trees as they stood, and the protocol's worktree-safety rule (a swap never commits, pushes, stashes, resets or cleans) is right to. Until now nothing retired them afterwards except a person doing it by hand. This makes that procedure one act with a dry run, a rescue before every removal, and one register line per tree.

How it decides

The disposition table, as implemented (first matching row decides)

tree disposition --yes
not in the inventory foreign nothing
a process's cwd or open file in it (/proc, else lsof), a tmux pane in it, named by --live, the sweep started inside it, or its recording session live live nothing — re-checked at the moment of the act
directory gone, still registered prune git worktree remove <path> (that one registration)
directory gone, unregistered gone nothing
locked registration; dirty or unpushed submodule; a repo nested inside; a clone with a branch/stash origin lacks keep nothing, with the reason
something leans on it (alternates, local-path remote) load-bearing never removed; dependents and remedy named
dirty / untracked wip-rescue+remove git add -A into a copy of the index → commit-tree → rescue/<lane>/<slice>-<UTC> pushed and seen on origin, bundled, removed
detached, on origin remove removed
detached, own commits rescue+remove rescue branch pushed, bundled, removed
merged (register or gh) remove+delete-branch removed; local branch deleted; remote too where its Lane: trailer is this lane's and it holds nothing beyond the PR
clean, everything on origin remove removed; branch stays (an open PR untouched)
unpublished on main/master, or origin diverged rescue+remove tip to a rescue branch; never force-pushed
unpublished otherwise push+remove pushed as is under its own name — never its upstream's

At removal the head is re-read and the tree must still be clean, or byte for byte the rescued tree. A refused push leaves the tree exactly as it was (the WIP commit is made beside it). Ignored non-cache, non-build files (an .env) are archived first. Also --branches, --include-scratch (tar + sha256 → remove), --include-caches, --include-sandboxes (owning pid gone), --links, --bundle, and sweep --expire (90 days, sweep.conf; never an archive whose rescue branch left origin). Every archive carries MANIFEST.sha256, DISPOSITION.md and rescues.tsv; one NOTED line per tree acted on, written after the act.

The exit contract for #163

lane-worktrees sweep <lane> --dry-run --porcelain: lane row, optional refused row, one tree/branch/scratch/cache/sandbox/link row each, summary <to retire> <trees> <live> <foreign>; exits 0 nothing to retire, 3 something to retire, 2 refused. --yes: 0 done, 1 an act failed part-way; usage 64.

Two defects the real-data dry run caught before review

  1. A branch made with git worktree add -b X origin/main tracks origin/main; "push to its upstream" would have pushed a lane's stray commits onto main. The push target is now always the branch's own name (read_tree, with a comment).
  2. With --yes the fetch ran before the lane gate refused, so a refused run still wrote FETCH_HEAD. The gate now decides before any write; the parametrized refusal case snapshots the whole estate.

Tests

tests/test_lane_worktrees.py (40 cases after review rounds 1-2 and the adversarial review; fake lanes-edit.sh/gh/tmux, bare origin behind a url.insteadOf, everything under tmp_path; the two last cases run the real lanes-edit.sh):

case holds
every disposition row in the dry run 13 states row for row; whole-estate snapshot unchanged; no sweeps dir; no register line
--yes performs the table pushes, rescue branches on origin, WIP subject (lane/slice/UTC/"swept, not reviewed") and parent, bundles, .env archived without bytecode, manifest verifies, one NOTED per tree with no , /—/reserved words
rescue on origin before removal origin refuses rescue/* → exit 1, tree byte-identical, branch/index unmoved, no NOTED
a live writer is never touched process cwd and a tmux pane
the coordinator states its writer count holder = this session: --yes refused without --live; --live <path> keeps it
refused live elsewhere / unread (×4) bound elsewhere, binding unread, held by another session, holder unread → 2 for dry run and --yes, estate unchanged
managed lane refused
merged = register or gh, never ancestry register LANDED beats a stale OPEN; a tip on main with no PR keeps its branch
without gh nothing is merged LANES_NO_GITHUB=1
--include-foreign takes a word refused without --word; another lane's tree never taken
--branches merged deleted (+remote when ours), theirs local only, open PR kept, unpublished listed with tip/distance/owner, rescue/* untouched
scratch and caches archived without caches; a kept tree's caches removed, a live tree's left
sandboxes dead .lock pid and old tmp.* removed; young, held and live-lock kept
--links dangling symlink and moved gitdir pointer listed
load-bearing clone --shared dependent → never removed, dependents + repack named
--expire 89 kept; 90 and no-rescue expired; kept when the rescue is gone from origin, when it is on origin at another SHA, when the archive has no manifest, and when it holds a file its manifest does not list; retention_days honoured
porcelain exit codes 0 / 3 / 2 and the lane/summary rows
usage 64
real helper: inventory and register line set-lane-tree inventory → removed; FOREIGN left; the NOTED line lands in the lane's own log
real helper: bound on another host 2 for dry run and --yes
round 1: merge into another base / open PR first release/1 merge is no landing; open #43 protects despite merged #42; both branches kept
round 1: contested tree another lane's inventory also names it → kept
round 1: clone with linked worktrees kept
round 1: unreadable inventory record refused, exit 2, dry run and --yes, estate unchanged
round 1: lane name resolved first REPOA-1 → repoA-1 via canon-lane; 66 refuses
round 1: caches of a FOREIGN tree never taken
round 1: branch deletes need a fetch that worked origin unreachable under --yes → keep, branch intact
round 1: origin-less worktree, --bundle --yes bundled and removed, as the dry run says
round 1: unreadable distance to origin origin's tip object missing → keep

Plus the installer/hygiene suites updated for 17 files / 31 artifacts and lane-worktrees compiled beside lanes-index. Locally every case above was exercised; the tests/run.sh run is queued behind other estates' pytest runs on Eagle, so CI is the record.

Measurements — Eagle, lane openRepoTools-3, read-only dry runs (nothing was run with --yes)

run time exit what it WOULD do
sweep openRepoTools-3 at 7e58b98 10.3 s 0 13 trees, all FOREIGN: the lane's #97 inventory is empty (snapshot NONE — no handoff since #97 landed). This exit 0 was the B1 defect: the gate passed a lane with 7 trees under its own root
sweep openRepoTools-3 --dry-run --porcelain at 296e2f0 12.6 s 2 refused: snapshot NONE, yet 9 trees are the lane's (7 under .lane-worktrees/openRepoTools-3/, 2 in .claude/worktrees by their Lane: trailer); summary 9 13 4 4
sweep openRepoTools-3 --yes --live none at 296e2f0 0.1 s 2 refused: --yes is disabled until opensoft/openRepoTools#170 lands
… --include-foreign --branches --include-scratch --include-caches 11.1 s 3 7 live (3 by a process's cwd — incl. the sibling a14-worktrees writer and this writer's own tree — and 4 FOREIGN active within 24 h); 5 remove+delete-branch (nw-a18, nw-a18-add1, nw-a18-add2, nw-a19 merged by register LANDED; feat/claude-current by gh, PR #134); 1 remove (001-separate-swap-ctx-handoff, clean, on origin); 2 review branches listed, never deleted; 7 scratch dirs to archive (~148 MB: featlist-scratch 86.7 MB, dash-scratch 38.6 MB, wb71-scratch 19.2 MB, a11-scratch 3.2 MB, three small); 0 caches outside the scratch
… --links 27 s (the walk: 245 s → 16.7 s after the scandir commit) 0 402 broken links across /workspace/projects, 99 of them worktree gitdir pointers
… --include-sandboxes 15.7 s 0 61 mktemp sandboxes in /tmp, each over an hour old with no live process standing in, holding or naming it
sweep --expire 0.2 s 0 no archives yet

Review round 1 (Copilot, 23 threads)

22 were taken in bf066fd and documented in ae17742. Each new or extended case fails against 7e58b98 and passes at ae17742:

  • merged requires the default branch as base, and open PRs are checked first;
  • a failed rev-list is a read error, never "0 ahead";
  • clones whose git directory linked worktrees share are kept;
  • contested inventory claims are kept;
  • unreadable inventory records refuse the sweep;
  • canon-lane runs first;
  • liveness is asked afresh before the rescue and again just before removal, and an unavailable scan leaves the tree;
  • branch deletes are fenced to the judged SHA (update-ref -d <old>, --force-with-lease) and every failure is counted;
  • + remote is written only when the remote delete happened;
  • --branches fetches its checkouts, and a failed fetch deletes nothing;
  • no repository-wide prune;
  • a failed ignored-file listing stops the act;
  • archive directories are created exclusively;
  • scratch removal requires no new writer and unchanged content;
  • caches never come from a FOREIGN, live or unknown tree, and the owning tree is rechecked at the act;
  • an origin-less tree under --bundle --yes is bundled and removed;
  • --expire requires the exact rescued SHA and a whole archive (manifest-verified, rescues.tsv parseable).

The one not taken (tmp.* sandboxes, which carry no ownership evidence) is a design question about #162's own table, filed as #170. Every thread was answered and resolved.

The read-only dry run on this lane at ae17742 gives the same answer as before (exit 3, 10.5 s): 7 live, 5 remove+delete-branch, 1 remove, 7 scratch. The open PR's own branch feat/lane-worktrees-sweep is now keep.

Review round 2 (Copilot, 8 threads)

7 were taken in 3c0b188. Each new or extended case fails against ae17742:

  • an unreadable claim by another lane refuses the sweep before any write;
  • a submodule's ignored .env keeps the tree;
  • ignored files are re-listed at removal and compared with what was archived;
  • a reappeared prune candidate is left;
  • a pytest sandbox's .lock is re-read at the act;
  • tracked() reports an unknown when it cannot read, and the cache is left;
  • a manifest entry whose file is gone makes the archive not whole.

The one not taken (unreadable /proc entries of same-account processes) is #170 item 2. On Eagle there are always about 9 of them (6 timeout, 3 ssh-agent), so reading them as unknown would keep every tree on every sweep.

Copilot's third round (5 threads, after the cap): filed as #170 items 4-8 and resolved. These are real --yes / --expire --yes data-loss paths:

  • tag-only commits in a clone;
  • a submodule's unpublished branches or stash;
  • a mirror-style fetch refspec under --prune;
  • scratch holding nested trees;
  • a bundle-only rescue missing from the ledger.

They should be fixed before the sweep's first --yes on real trees. The dry run, which is all #163's gate reads, is unaffected.

CI at 3c0b188: green. tests 959 passed; tests-no-submodule 751 passed, 208 skipped; tests-windows 153 passed, 806 skipped; guard-launch-mode and parse-macos pass; tests-macos waits for the ready label.

The Opus adversarial review of 3c0b188 (standing in for Codex, which is over quota)

Its verdict was "must not land as is", reproduced with probes against the suite's own Estate fixture. Taken in 5f4bd84 and documented in 296e2f0:

  1. --yes and --expire --yes are switched off (exit 2, nothing changed) unless LANE_WORKTREES_ENABLE_YES=1 is set. The test Estate sets it; the manual says it is not for a real estate until lane-worktrees: review findings deferred from #168/#169 (incl. five --yes data-loss paths found after the two-round cap) #170 lands. The dry run, --porcelain and --report are unaffected.
  2. B1: an unrecorded tree under .lane-worktrees/<lane>/, or in .claude/worktrees on a branch whose own commits carry the lane's Lane: trailer, is the lane's. A NONE snapshot with such trees is refused, exit 2. Eagle went from exit 0 to exit 2 (table above).
  3. B2: subprocess output and git metadata files are decoded with surrogateescape, and an unreadable pytest-of-$USER is a kept row. A catch-all keeps errors no read caught inside the contract: exit 2 on a dry run; under --yes, DISPOSITION.md first, then exit 1.
  4. B3: porcelain fields escape \\, TAB, LF and CR, and registrations are read with git worktree list -z. The escaping is documented in --help and the manual.

There are five new cases (40 in all), each failing against 3c0b188. A1-A11, B4-B6, C1, C4-C6 and E1-E7 are in #170's new table; the --yes switch stands until they land.

Part of #162. Refs #97 #161 #163 #164 brettheap/new-workstation#48

Lane: openRepoTools-3

🤖 Generated with Claude Code

Summary by Sourcery

Add a safety-gated, rescue-first sweep command for retiring abandoned lane worktrees without touching live writers or losing recoverable data.

New Features:

  • Add the lane-worktrees sweep command for dry-run inspection and controlled retirement of abandoned lane worktrees, branches, scratch areas, caches, sandboxes, and links.
  • Provide rescue-first cleanup with origin or bundle preservation, liveness protection, merged-branch handling, archive retention, porcelain output, and defined exit codes.

Bug Fixes:

  • Prevent pushes to an upstream such as main by targeting unpublished work under its own branch name.
  • Refuse before fetching or writing when lane ownership, reconciliation, or safety information cannot be established.

Enhancements:

  • Expand installer support and documentation from sixteen to seventeen shipped files and thirty-one artifacts.
  • Add comprehensive coverage for worktree dispositions, safety refusals, rescue behavior, archives, retention, porcelain output, and real helper integration.

Documentation:

  • Document sweep usage, ownership rules, disposition behavior, safety guarantees, archive format, retention configuration, and the porcelain contract.

Tests:

  • Add an extensive lane-worktrees test suite covering dry runs, cleanup actions, liveness, ownership, branches, caches, sandboxes, links, bundles, expiration, encoding, and refusal cases.

Chores:

  • Compile and ship lane-worktrees alongside the existing command set.

brettheap and others added 5 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>
Copilot AI balanced review requested due to automatic review settings October 5, 2026 20:32
@brettheap

Copy link
Copy Markdown
Contributor Author

@codex review

@sourcery-ai

sourcery-ai Bot commented Oct 5, 2026

Copy link
Copy Markdown

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

You can request another review in 4 days by commenting @sourcery-ai review. Upgrade to get a review now.

@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.

@sourcery-ai

sourcery-ai Bot commented Oct 5, 2026

Copy link
Copy Markdown

Reviewer's Guide

Introduces a safety-first lane-worktrees sweep command that inventories and gates lane ownership, protects live writers, rescues recoverable work before any deletion, archives evidence, and exposes porcelain exit semantics; integrates it into the installer and documentation with broad isolated-estate coverage.

Sequence diagram for a safe lane worktree rescue and removal

sequenceDiagram
    participant Sweep as lane-worktrees
    participant Reconcile as lanes-edit.sh
    participant Git as Git repository
    participant Origin as Origin remote
    participant Archive as Sweeps archive
    participant Register as Lane register

    Sweep->>Reconcile: lane-reconcile
    Reconcile-->>Sweep: ownership and writer state
    Sweep->>Git: fetch
    Sweep->>Git: git add -A on a copy of the index
    Sweep->>Git: commit-tree
    Sweep->>Origin: push rescue branch
    Origin-->>Sweep: rescue branch visible
    Sweep->>Archive: write bundle and manifest
    Sweep->>Git: re-read head and verify tree
    Sweep->>Git: git worktree remove path
    Sweep->>Register: lanes-edit.sh log NOTED
Loading

State diagram for lane worktree sweep dispositions

stateDiagram-v2
    [*] --> Inventory
    Inventory --> Foreign: not in lane inventory
    Inventory --> GateRefused: ownership or reconciliation refused
    Inventory --> Live: live writer detected
    Inventory --> Unsafe: locked, dirty dependency, nested repo, or load-bearing
    Inventory --> Candidate: eligible tree
    Candidate --> Rescue: dirty or unpublished work
    Candidate --> Remove: clean and recoverable
    Candidate --> Prune: registered path is gone
    Rescue --> Remove: rescue pushed and bundled
    Remove --> Noted: removal verified
    Prune --> Noted: registration removed
    Foreign --> [*]
    GateRefused --> [*]
    Live --> [*]
    Unsafe --> [*]
    Noted --> [*]
Loading

Flow diagram for the lane-worktrees sweep decision process

flowchart TD
    A[lane-worktrees sweep lane] --> B[Read inventory and disk]
    B --> C[Read lane-reconcile before writes]
    C --> D{Gate refused?}
    D -->|Yes| E[Report refused and exit 2]
    D -->|No| F[Classify trees and related items]
    F --> G{Live writer or foreign?}
    G -->|Live| H[Report live and never touch]
    G -->|Foreign| I[Report foreign and leave]
    G -->|Candidate| J{Recoverable before removal?}
    J -->|Dirty or unpublished| K[Rescue to origin and bundle]
    J -->|Clean and safe| L[Remove or prune registration]
    J -->|Unsafe or load-bearing| M[Keep with reason]
    K --> N[Re-check head and tree]
    L --> N
    N --> O{--yes?}
    O -->|No| P[Dry-run table]
    O -->|Yes| Q[Archive evidence and write NOTED]
    P --> R[Porcelain summary and exit 0 or 3]
    Q --> S[Exit 0 or 1]
Loading

File-Level Changes

Change Details Files
Add the lane-worktrees sweep command with dry-run and destructive execution modes, including lane ownership, reconciliation gates, liveness checks, disposition classification, rescue-before-removal, archival, and register logging.
  • Discover candidates from the lane inventory and filesystem while distinguishing foreign, missing, locked, load-bearing, and live trees.
  • Refuse unsafe operations for remote bindings, unreadable reconciliation state, managed lanes, other live sessions, and unacknowledged writers.
  • Classify branches and worktrees using origin state plus register/GitHub merge evidence, avoiding ancestry-only merge detection.
  • Rescue dirty, detached, diverged, or otherwise unpublished work to origin-backed rescue branches or bundles before removal.
  • Re-check tree state and liveness immediately before acting, preserving trees when pushes or safety checks fail.
  • Write sweep archives, manifests, disposition records, rescue metadata, ignored-file/scratch archives, and post-action NOTED register entries.
  • Support optional branch, scratch, cache, sandbox, link, foreign, bundle, and archive-expiration sweeps with configurable retention and safety thresholds.
lane-worktrees
docs/README-lanes.md
Define and test the machine-readable sweep contract and comprehensive safety behavior across simulated and real lane-helper estates.
  • Add 23 test scenarios covering every disposition, dry-run immutability, rescue failures, live writers, coordinator writer counts, refusal states, merge evidence, foreign trees, auxiliary artifacts, load-bearing clones, expiration, porcelain output, and usage codes.
  • Exercise real lanes-edit.sh inventory/reconciliation/register behavior in end-to-end cases.
  • Use isolated temporary Git estates with fake GitHub, tmux, and helper integrations, including origin URL rewriting and snapshot-based no-change assertions.
tests/test_lane_worktrees.py
Integrate lane-worktrees into installation, documentation, artifact accounting, and hygiene checks as the seventeenth installed file and thirty-first artifact.
  • Fetch, install, report, and validate the new command alongside existing artifacts while preserving all-or-nothing installation planning.
  • Update user-facing counts, command listings, installer expectations, and historical artifact documentation.
  • Compile the shipped Python command in repository hygiene tests.
openRepoTools
README.md
AGENTS.md
tests/test_install_skill_and_hook.py
tests/test_openrepotools_command.py
tests/test_repo_hygiene.py

Possibly linked issues


Tips and commands

Interacting with Sourcery

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

Customizing Your Experience

Access your dashboard to:

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

Getting Help

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

Unresolved cleanup safeguards can delete active resources or recovery data without sufficient ownership, liveness or preservation checks.

Review effort: Balanced
Findings: 16 High severity · 7 Medium severity

Open (23)

And 3 more that still need to be addressed.

What changed in this PR

Adds the core lane-worktree retirement command for #162, providing cleanup and recovery support ahead of lifecycle integration.

Changes:

  • Adds dry-run planning, rescue, cleanup and archive retention.
  • Installs lane-worktrees as the seventeenth shipped file.
  • Documents behavior and adds isolated regression coverage.
File Description
tests/​test_repo_hygiene.py Compiles the new Python command.
tests/​test_openrepotools_command.py Updates installer and help expectations.
tests/​test_lane_worktrees.py Adds sweep and recovery tests.
tests/​test_install_skill_and_hook.py Updates artifact-count wording.
README.md Updates installation documentation.
openRepoTools Installs and advertises the command.
lane-worktrees Implements sweep planning and execution.
docs/​README-lanes.md Documents safeguards, options and exit codes.
AGENTS.md Updates the shipped-file count.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread lane-worktrees Outdated
Comment thread lane-worktrees
Comment thread lane-worktrees Outdated
Comment thread lane-worktrees
Comment thread lane-worktrees Outdated
Comment thread lane-worktrees Outdated
Comment thread lane-worktrees Outdated
Comment thread lane-worktrees Outdated
Comment thread lane-worktrees Outdated
Comment thread lane-worktrees
brettheap and others added 2 commits October 5, 2026 21:22
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>
brettheap added a commit that referenced this pull request Oct 5, 2026
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 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

Unresolved ownership, liveness, and content-preservation checks can allow deletion of live or unarchived work.

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

Open (8)
Resolved since last review (23)

And 3 more resolved.

Comment thread lane-worktrees
Comment thread lane-worktrees
Comment thread lane-worktrees Outdated
Comment thread lane-worktrees
Comment thread lane-worktrees
Comment thread lane-worktrees
Comment thread lane-worktrees Outdated
Comment thread lane-worktrees
Copilot AI balanced review requested due to automatic review settings October 5, 2026 21:39
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 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.

Comment thread lane-worktrees
Comment thread lane-worktrees
Comment thread lane-worktrees
Comment thread lane-worktrees
Comment thread lane-worktrees
brettheap and others added 2 commits October 5, 2026 23:28
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>

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.

Comment thread lane-worktrees Outdated
Comment thread lane-worktrees
Comment thread lane-worktrees
Copilot AI balanced review requested due to automatic review settings October 5, 2026 23:45
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>

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

🔵 Needs a closer look

Ownership and dry-run correctness concerns, alongside deferred cleanup hazards, require a final human landing decision.

Review effort: Balanced
Findings: 3 High severity

Open (3)
Resolved since last review (3)

Comment thread lane-worktrees
Comment thread lane-worktrees
Comment thread lane-worktrees
Copilot AI balanced review requested due to automatic review settings October 6, 2026 09:40
brettheap added a commit that referenced this pull request Oct 6, 2026
Three files conflicted. Each one was resolved by keeping #121's text and
adding this branch's on top:
- lane-start: both sides added a section just before "6. launch". #121's
  5c (the restart intent is written before exec) comes first, then 5e
  (the daily report). The report therefore starts only after the intent
  write, which can refuse the launch.
- lanes-edit.sh: both sides extended the unknown-subcommand list. The
  list keeps #121's five new entries (lane-holders,
  legacy-restart-check, publish-handoff, restart-intent,
  set-restart-intent) and adds pathspec-check.
- tests/test_lane_helpers.sh: both sides added a section before
  "nothing real touched". #121's supervised-restart section comes first,
  then the #162 bytecode section.

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

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

🔵 Needs a closer look

Unresolved inventory handling and deferred cleanup safety risks require final human review.

Review effort: Balanced
Findings: 1 High severity

Open (1)
Resolved since last review (3)

Comment thread lane-worktrees
Bring #168 current with main so the lander can squash it on a green
head. The merge was clean: #171 and #168 share only
docs/README-lanes.md, where git placed #171's worktrees-index section
and #168's lane-worktrees section without overlap, and both stay.
#171 adds no placed file, so the install lists and their counts keep
lanes-index (#164) and lane-worktrees (#168) as they were.

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

sonarqubecloud Bot commented Oct 6, 2026

Copy link
Copy Markdown

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

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

Lane: openRepoTools-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap
brettheap merged commit 2080f3d into main Oct 6, 2026
13 of 14 checks passed
@brettheap
brettheap deleted the feat/lane-worktrees-sweep branch October 6, 2026 18:45
brettheap added a commit that referenced this pull request Oct 6, 2026
Bring #169 current with main after #168 landed as 2080f3d, so the
lander can squash #169 on a green head. Main also carries #171
(f5444c5) and #172 (3660224). The merge base is 00971b9 because the
squash is not an ancestor of this branch, so git saw #168's files as
added on both sides. Four files conflicted:
- lane-worktrees (add/add) and README.md: main's copy is byte-identical
  to #168's old head 87da332, and main changed neither file after it,
  so this branch's copy (#168 plus #169) is the resolution.
- docs/README-lanes.md: the one block is #169's two report subsections
  against nothing on main's side. They stay, and #171's worktrees-table
  subsection, which merged cleanly, stays too.
- lanes-edit.sh: #171 and #169 each added a usage line, a dispatcher arm
  before `*)`, and a refusal-list entry. Main's text comes first, then
  #169's: the `worktrees` line and arm, then `pathspec-check`. The list
  ends `|set-restart-intent|worktrees|pathspec-check`, so it still
  matches the arms the repo-hygiene parser reads (64).
Changed-line comparison: this merge over main equals #169's own diff,
and over 45fa45d equals main's changes since 87da332 (#171 and #172).
The only difference in each is the refusal-list line that carries both
entries.

Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 6, 2026
tests-macos on #168's head df0d691 failed one case, 1160 passed:
test_a_path_that_is_not_utf8_is_a_row_not_a_traceback. Its fixture's
`git worktree add` of a Latin-1 path died with "fatal: could not create
directory of '.git/worktrees/caf\xe9': Illegal byte sequence". APFS
refuses a non-UTF-8 name, so no such worktree can exist there, and the
case has nothing to report. It failed with a CalledProcessError before
reaching its assertion.

The fixture now makes the raw-bytes directory first. Where the
filesystem refuses it (OSError), or git refuses the add, the case skips
and says why. Linux accepts the name, so the case runs there as before:
git adds a worktree into an existing empty directory.

Refs #177
Lane: openRepoTools-3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 6, 2026
… the code (#162, part 2) (#169)

* 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>

* Skip the non-UTF-8 path case where the filesystem refuses the name

tests-macos on #168's head df0d691 failed one case, 1160 passed:
test_a_path_that_is_not_utf8_is_a_row_not_a_traceback. Its fixture's
`git worktree add` of a Latin-1 path died with "fatal: could not create
directory of '.git/worktrees/caf\xe9': Illegal byte sequence". APFS
refuses a non-UTF-8 name, so no such worktree can exist there, and the
case has nothing to report. It failed with a CalledProcessError before
reaching its assertion.

The fixture now makes the raw-bytes directory first. Where the
filesystem refuses it (OSError), or git refuses the add, the case skips
and says why. Linux accepts the name, so the case runs there as before:
git adds a worktree into an existing empty directory.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request 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>
brettheap added a commit that referenced this pull request Oct 6, 2026
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>
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