diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 614b7b99..7c0d16ca 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -326,6 +326,7 @@ lane-start 2 # from the cwd's git root lane-end openRepoShape-2 # the window is closing lane-end openRepoShape-2 --retire # …and the lane is not coming back +lane-end openRepoShape-2 --inventory-only # …past the close-out gate (#163), trees left recorded ``` Both print every step to **stderr** and refuse with a message that names the @@ -516,6 +517,42 @@ history in it to preserve. `--state-was ""` named the exact text the old two-step had to replace; it is **accepted and ignored**, and a re-run that passes it does the same thing a re-run without it does. +**It refuses (exit 2) while anything of the lane's is still on disk** — the +close-out gate of opensoft/openRepoTools#163, asked after the in-flight guard +and before any write, `--dry-run` included. The question is #162's sweep as a +dry run, `lane-worktrees sweep --branches --include-scratch +--include-caches --dry-run --porcelain` ([its exit +contract](#the-exit-contract-lane-ends-gate-163)): **3** (something to retire) +and **2** (the sweep refused the lane, or a read failed) are a refusal that +names every tree, branch, scratch directory and cache the table gives the lane, +relays the sweep's own reason where it refused, and prints the table's command +and the `--yes` that would retire them — which removes only what is on origin +or in a bundle, never a live writer's tree. **Any other exit, or +no `lane-worktrees` beside the command, is a gate nobody read: exit 1, never a +pass.** Beside the table the gate makes two reads of its own. Every tree of the +lane's is read with `git status --porcelain --ignored`, so a tree holding only +an `.env` or a `node_modules` is named as holding them and never as clean. And +every entry directly under the lane's own root, `.lane-worktrees//`, that +no tree, scratch or cache row names is **residue** — the ignored leftovers no +row of the sweep reaches — and it refuses even where the sweep answered 0, as +a cache row does; a root that cannot be listed is the gate unread (exit 1, +whatever the sweep answered), never an empty one. Other lanes' trees in a shared checkout are FOREIGN, are +counted for nobody, and never hold this lane — but a FOREIGN row names nothing +for the residue read either, so a standalone clone directly under the lane's own +root, which the sweep keeps FOREIGN, is residue and holds it. + +`--inventory-only` is **the one door past the gate**: the lane ends although the +gate found something, refused, or could not be read, nothing on disk is touched, +and the trees stay in the lane's #97 inventory, where the daily report lists them +as an ended lane's. The reason it was let past is **written down**: the row's +closing phrase gains `ENDED WITH --inventory-only: ; they stay +in its #97 inventory`, and the lane's own `ENDED` (or `RETIRED`) line carries the +same words in full. It does not pass the in-flight guard — that is `--force`'s, +and a lane with both wants both. **`--sweep` is not built**: retiring the trees +in the same run as the ending is not one act, so the word is refused by name +rather than read as an unknown option; `lane-worktrees sweep --yes` +retires them first, on its own. + `lane-end --retire-dormant ` is a **different act again** and ends no lane: it is Amendment 19(c)'s sweep of the rows that have no lane left to end. It is described under [A closed or dormant lane leaves the @@ -3590,6 +3627,7 @@ touched.** lane-worktrees sweep # the DRY RUN: the table, nothing changed lane-worktrees sweep --yes [--live |--live none] lane-worktrees sweep --dry-run --porcelain # lane-end's gate (#163): 0 / 3 / 2 +lane-worktrees add [--branch ] [--from ] # make a tree AND record it (#163) lane-worktrees sweep --expire [--yes] # archives past retention ``` @@ -3799,6 +3837,15 @@ tree summary ``` +**`lane-end` reads it with `--branches --include-scratch --include-caches`** and +refuses on 3 and on 2 — and on 0 too where a `cache` row is printed, because a +cache under the lane's root is still the lane's to clear though never its work to +retire, or where its own read of the lane's root finds residue no row names +(see [`lane-end `](#lane-end-lane)). It counts the rows whose second field +is `retire`, plus every `cache` row; a FOREIGN tree, another lane's branch and a +`gone` record count for nothing. It passes `LANES_EDIT` through and sets +`LANES_NO_FETCH=1`, having fetched in its own reads. + Every field is escaped — a backslash is written `\\`, a TAB `\t`, a newline `\n` and a carriage return `\r` — so a path holding any of them is still one field of one row; bytes that are not UTF-8 pass through as the bytes they are. The @@ -3819,7 +3866,8 @@ is 64, a `--live` path that names no tree included. ### Two protocol lines this act assumes (proposed for the amendment that ratifies it) 1. **A lane creates worktrees, never clones.** Every tree a lane works in is a - `git worktree` of the estate's checkout, recorded in its inventory (#97). A + `git worktree` of the estate's checkout, recorded in its inventory (#97) — + which `lane-worktrees add` does in one act (below). A standalone clone under a lane root is FOREIGN to every sweep, and one that is a shared store or a local remote is LOAD-BEARING. A lane adds no local remote and no alternates. @@ -3829,6 +3877,44 @@ is 64, a `--live` path that names no tree included. goes to `${XDG_STATE_HOME:-$HOME/.local/state}/openRepoTools/lanes//scratch/` or the harness scratchpad. +### Making a lane's worktree — `lane-worktrees add` (#163) + +**Creation records the tree, in the same act.** Nothing in this tooling made a +worktree before this verb — `lane-start` makes none, and every writer's tree was +a `git worktree add` typed into a brief — so the inventory was written only at a +handoff, and a lane that never handed off had none: lane `openRepoTools-3`'s was +empty while nine trees under its root were its own, and the sweep could not +judge them. + +```sh +lane-worktrees add [--branch ] [--from ] [--checkout ] [--dry-run] +``` + +It makes `/.lane-worktrees//` as a `git +worktree` of the lane's recorded checkout (or `--checkout `, the root of a +checkout), on `--branch` (default: the slice) — checked out where that branch +exists, otherwise made from `--from` (default `HEAD`) **with `--no-track`**, so +a bare `git push` never aims a writer's commits at the branch it started from — +and then records it with `lanes-edit.sh set-lane-tree --checkout +` (with `--writer` where the caller's session is a transcript uuid). The +path is the one line on stdout. It refuses, exit **2** and nothing made, a +managed lane or one whose ownership could not be read, a lane that records no +directory, an inventory that cannot be read, a path that already exists, a +branch name git does not take, and `--from` beside a branch that already exists. +**A record that fails after the tree is made — or a `git worktree add` that +fails after making it, as one does when a `post-checkout` hook fails — takes the +fresh tree and the branch this act made back out** (`git worktree remove`, +without `--force`, and `update-ref -d` at the commit it made) and exits 2; only +where something is left — the undo failed, a directory git does not register, +which is named and never deleted blind, or a new branch that has moved past the +commit this act started it at, which is kept so that commit stays reachable — is +the exit **1**, with the `set-lane-tree` that records it printed. Usage +is 64, and every flag of the sweep's is usage here. + +A tree made any other way — a brief's own `git worktree add`, the harness's +`isolation: worktree` — is still legal git and is FOREIGN to every sweep until +`lanes-edit.sh set-lane-tree ` records it. + ### The estate report — `sweep --all --dry-run --report` The net under every actor that never runs `lane-end`: **creation outpacing diff --git a/lane-end b/lane-end index 0fec0ecf..cde8c280 100755 --- a/lane-end +++ b/lane-end @@ -23,6 +23,10 @@ # # USAGE # lane-end [options] # lane-end openRepoShape-2 +# lane-end --inventory-only # end it past the CLOSE-OUT GATE: its +# # trees, branches, scratch and caches +# # stay where they are, and the row and +# # the ENDED line say so (#163) # lane-end --retire # retire a live FORK (pid or uuid) or a # # DUPLICATE (pid); ends nothing # lane-end --retire-dormant [--reason ""] [--yes] @@ -109,7 +113,22 @@ # one word it opened with. The cell is now one phrase and # the closing write carries RETIRED itself # --force end the lane even though its object log still shows -# something open, and say so in the lines written +# something open, and say so in the lines written. It +# does not pass the close-out gate: that is the next word +# --inventory-only THE DOOR PAST THE CLOSE-OUT GATE (below): end the lane +# although the sweep still finds trees, branches, scratch +# or caches of it on disk, refused the lane, or could not +# be read. Nothing on disk is touched and the trees stay +# in the lane's #97 inventory, where the daily report +# lists them as an ended lane's; the row's closing phrase +# and the lane's ENDED (or RETIRED) line say that this +# door was taken and what the gate found. Where the gate +# passes it changes nothing +# --sweep NOT BUILT, and refused with that said. Retiring the +# trees in the same run as the ending is not one act: +# `lane-worktrees sweep --yes` retires them first, +# on its own; the word is held here so that nothing else +# takes it meanwhile # --home owner/repo the lane's home repo, for a PRE-CUTOVER lane whose log # has no STARTED line. Accepted for parity with `claim` # and `who` and passed through to the helper; lane-end's @@ -172,6 +191,25 @@ # peer's LANDING pushed on to this row was invisible to the one path that # still decides a merge hold from prose. # +# THE CLOSE-OUT GATE (opensoft/openRepoTools#163) +# After the in-flight guard and before any write, the lane's own leftovers +# are asked about: `lane-worktrees sweep --branches --include-scratch +# --include-caches --dry-run --porcelain`, which exits 0 (nothing of the +# lane's to retire), 3 (something to retire) or 2 (refused, or a read +# failed). 3 and 2 are a REFUSAL, exit 2, naming every tree, branch, scratch +# directory and cache the table gives the lane, the sweep's own reason where +# it refused, and the exact sweep command; any other exit, or no +# `lane-worktrees` at all, is a gate nobody read (exit 1), never a pass. +# Beside the sweep's table, this command makes two reads of its own: +# * every tree of the lane's is read with `git status --porcelain +# --ignored`, so a tree holding nothing but ignored files (an `.env`, a +# `node_modules`) is named as holding them, never as clean; +# * every entry directly under the lane's own root, `.lane-worktrees/ +# /`, that the table does not name as a tree, scratch or a cache is +# RESIDUE - ignored leftovers the sweep's rows never reach - and refuses +# even where the sweep answered 0, as a cache row does. +# `--inventory-only` is the one way past it, and `--sweep` is not built. +# # THE HANDOFF FRAGMENT # On STDOUT, generated from the lane's log, and partitioned by the same two # sets the refusal uses, so no object can fall out of both: @@ -190,13 +228,19 @@ # # EXIT # 0 done -# 1 environment — no register, no writer, or `lanes-edit.sh lane-objects` -# failed (which is NOT the same as the lane having no object log) -# 2 refusal — bad arguments, no row (or two), or something still in flight +# 1 environment — no register, no writer, `lanes-edit.sh lane-objects` +# failed (which is NOT the same as the lane having no object log), or the +# close-out gate could not be read (no `lane-worktrees`, or an exit that +# is none of its three) +# 2 refusal — bad arguments, no row (or two), something still in flight, or +# the close-out gate: the lane's trees, branches, scratch, caches or +# residue still on disk, or the sweep refusing the lane # # ENVIRONMENT # LANES_FILE default /lanes/LANES.md # LANES_EDIT default lanes-edit.sh beside this command +# LANE_WORKTREES default lane-worktrees beside this command: the close-out +# gate's sweep # LANES_WORKSPACE_ROOT the workspace repository, overriding workspace.yaml's # `path:` — the test seam # AGENT_PROTOCOL_ROOT dir holding workspace.yaml (default ~/.agents) @@ -481,6 +525,18 @@ lanes_edit_default() { } LANES_EDIT="${LANES_EDIT:-$(lanes_edit_default)}" +# THE CLOSE-OUT GATE'S SWEEP (opensoft/openRepoTools#163), found the way the +# writer is: `--install` places `lane-worktrees` beside this command too. +lane_worktrees_default() { + local d + [ -x "$SCRIPT_DIR/lane-worktrees" ] && { printf '%s\n' "$SCRIPT_DIR/lane-worktrees"; return 0; } + d="${OPENREPOTOOLS_BIN_DIR:-$HOME/.local/bin}" + [ -x "$d/lane-worktrees" ] && { printf '%s\n' "$d/lane-worktrees"; return 0; } + command -v lane-worktrees 2>/dev/null && return 0 + printf '%s\n' "$SCRIPT_DIR/lane-worktrees" +} +LANE_WORKTREES="${LANE_WORKTREES:-$(lane_worktrees_default)}" + LE_TMP="" cleanup() { [ -n "${LE_TMP:-}" ] && rm -f -- "$LE_TMP"; return 0; } @@ -495,7 +551,7 @@ usage() { sed -n -e '1,2d' -e '/^#/!q' -e 's/^# \{0,1\}//' -e p "$RESOLVED"; } # ------------------------------------------------------------------ arguments retire=0; force=0; dry_run=0; state_was=""; home_override=""; retire_target="" -retire_dormant=""; sweep_reason=""; sweep_yes=0 +retire_dormant=""; sweep_reason=""; sweep_yes=0; inventory_only=0 positional=() # A TRANSCRIPT UUID AND A PID, TOLD APART FROM A LANE ARGUMENT BY SHAPE. @@ -560,6 +616,13 @@ while [ $# -gt 0 ]; do --home) home_override="${2-}"; [ -n "$home_override" ] || die "--home needs owner/repo" 2; shift 2 ;; --home=*) home_override="${1#--home=}"; [ -n "$home_override" ] || die "--home needs owner/repo" 2; shift ;; --force) force=1; shift ;; + # THE CLOSE-OUT GATE'S ONE DOOR, AND THE WORD HELD FOR ITS SECOND (#163). + # `--sweep` would retire the lane's trees in the same run as its ending, + # which is not built — `lane-worktrees sweep --yes` is that act, on its own + # — so the word is refused by name rather than read as an unknown option. + --inventory-only) inventory_only=1; shift ;; + --sweep | --sweep=*) + die "--sweep is not built: retiring a lane's trees in the same run as its ending is not one act. \`lane-worktrees sweep --yes\` retires them first, on its own (it removes only what is on origin or in a bundle, never a live writer's tree), and then $prog ends the lane; or $prog --inventory-only ends the lane with its trees left where they are, in its inventory. Nothing was written." 2 ;; --dry-run) dry_run=1; shift ;; --state-was) state_was="${2-}"; [ -n "$state_was" ] || die "--state-was needs the exact text to replace" 2; shift 2 ;; --state-was=*) state_was="${1#--state-was=}"; [ -n "$state_was" ] || die "--state-was needs the exact text to replace" 2; shift ;; @@ -587,6 +650,7 @@ done if [ -n "$retire_dormant" ]; then [ "$retire" = 0 ] || die "--retire and --retire-dormant are two different acts: --retire is a lane's own ending (or a live fork's), and --retire-dormant sweeps a repository's rows that have no lane left to end. Run one of them." 2 [ "$force" = 0 ] || die "--force ends a lane that still holds something; it has no meaning for a sweep of rows whose lanes ended before Amendment 7. Nothing was written." 2 + [ "$inventory_only" = 0 ] || die "--inventory-only is the door past ONE lane's close-out gate; the sweep of dormant rows ends no lane and asks no gate. Nothing was written." 2 [ -z "$state_was" ] || die "--state-was is for the state cell of a lane being ended; the sweep writes each row's cell itself, from the reason and the row's own last words. Nothing was written." 2 [ -z "$home_override" ] || die "--home is for a pre-cutover lane's own log line; the sweep writes no home into anything. Nothing was written." 2 [ "$dry_run" = 0 ] || sweep_yes=0 @@ -945,6 +1009,7 @@ fi # exactly as the fork case refuses to end a lane's row. if [ -n "$retire_target" ]; then [ "$force" = 0 ] || die "--force is for ending a lane that still holds something; it has no meaning for retiring a fork. Run: $prog $LANE --retire $retire_target" 2 + [ "$inventory_only" = 0 ] || die "--inventory-only is the door past a lane's close-out gate; retiring a fork ends no lane and asks no gate. Run: $prog $LANE --retire $retire_target" 2 [ -z "$state_was" ] || die "--state-was is for the state cell of a lane being ended; retiring a fork touches no cell. Run: $prog $LANE --retire $retire_target" 2 # THE SEAM, before any process is looked for or signalled (ruling 2026-10-04). seam_refuse "$LANE" "retiring a fork or a duplicate holder" @@ -1808,6 +1873,283 @@ elif [ "$in_flight" = 1 ]; then step "--force: ending with an open $cell_word in the state cell; the line written says so" fi +# ---------------------------------------- the close-out gate (#163) +# +# WORKSPACE CREATION OUTPACES WORKSPACE CLOSEOUT, and this is where the second +# catches up with the first (opensoft/openRepoTools#163, on Brett Heap's word +# of 2026-10-05). A lane ended while its writers' trees, their branches, its +# scratch and its caches are still on disk leaves them to nobody: the cleanup +# records show landing and retirement as separate acts for months, and the +# separate act not happening. So the end of a lane asks, exactly as it already +# asks about open objects, and refuses while the answer is not "nothing". +# +# THE QUESTION IS THE SWEEP'S DRY RUN, never a second implementation of it: +# `lane-worktrees sweep --dry-run --porcelain` is #162's exit contract +# for this gate — 0 nothing of the lane's to retire, 3 something to retire, 2 +# refused or a read failed — and its rows are what the refusal names. Any other +# exit, a missing command included, is a gate nobody read, and a read that +# failed is never an answer (Amendment 7(d)): exit 1, and nothing is written. +# `LANES_NO_FETCH=1` because this command has just fetched, in its own reads. +# +# TWO READS OF THIS COMMAND'S OWN SIT BESIDE IT, and both are about what the +# sweep's rows do not say. `git status --porcelain --ignored` in every tree of +# the lane's, because the sweep's own status read leaves ignored files out and +# a tree holding an `.env` or a `node_modules` is not a clean tree to name. And +# every entry directly under the lane's own root, `.lane-worktrees//`, +# that no tree, scratch or cache row names — a directory a removed tree left its +# ignored files in, a brief, a log — which the sweep never lists and which is +# still the lane's: RESIDUE, and it refuses even where the sweep answered 0. +# +# `--inventory-only` IS THE ONE DOOR, and it takes the lane past every answer +# but leaves every tree where it is and in the lane's #97 inventory; the reason +# it was let past is written into the row's closing phrase and the ENDED line. +# `--sweep`, which would retire them in the same run, is not built: the act +# that retires them is `lane-worktrees sweep --yes`, on its own. +TAB="$(printf '\t')" +gate_argv=(sweep "$LANE" --branches --include-scratch --include-caches) +gate_shown="lane-worktrees ${gate_argv[*]}" + +# A PATH'S PHYSICAL SPELLING, the way the sweep compares paths (`realpath`): +# a symlink under the lane's root is an entry of its own and is not followed. +gate_real() { # + gr_p="${1-}"; gr_r="" + if [ -d "$gr_p" ] && [ ! -L "$gr_p" ]; then + gr_r="$(CDPATH='' cd -P -- "$gr_p" 2>/dev/null && pwd -P)" || gr_r="" + else + gr_r="$(CDPATH='' cd -P -- "$(dirname -- "$gr_p")" 2>/dev/null && pwd -P)" || gr_r="" + if [ -n "$gr_r" ]; then gr_r="${gr_r%/}/$(basename -- "$gr_p")"; fi + fi + printf '%s\n' "${gr_r:-$gr_p}" +} + +# WHAT IS IN ONE TREE, IGNORED FILES INCLUDED. `GIT_OPTIONAL_LOCKS=0`: a read +# that refreshed the index would be a write, and this gate writes nothing. +gate_tree_state() { # + gts_p="${1-}" + if [ ! -d "$gts_p" ]; then printf 'its directory is gone'; return 0; fi + gts_top="$(git -C "$gts_p" rev-parse --show-toplevel 2>/dev/null)" || gts_top="" + if [ -z "$gts_top" ] || [ "$(gate_real "$gts_top")" != "$(gate_real "$gts_p")" ]; then + if ! ls -A -- "$gts_p" >/dev/null 2>&1; then + printf 'no checkout of its own, and what is in it could not be listed' + return 0 + fi + gts_n=0 + for gts_e in "$gts_p"/* "$gts_p"/.[!.]* "$gts_p"/..?*; do + if [ -e "$gts_e" ] || [ -L "$gts_e" ]; then gts_n=$((gts_n + 1)); fi + done + printf 'no checkout of its own; %s entr(ies) in it' "$gts_n" + return 0 + fi + gts_st="$(GIT_OPTIONAL_LOCKS=0 git -C "$gts_p" status --porcelain=v1 --ignored --untracked-files=normal 2>/dev/null)" || { + printf 'git status --ignored could not be read in it' + return 0 + } + gts_m=0; gts_u=0; gts_i=0 + gts_c="$(printf '%s\n' "$gts_st" | awk 'NF { k = substr($0, 1, 2); if (k == "!!") i++; else if (k == "??") u++; else m++ } END { printf "%d %d %d\n", m + 0, u + 0, i + 0 }')" + read -r gts_m gts_u gts_i < "$LE_TMP" + gate_out="$(LANES_EDIT="$LANES_EDIT" LANES_NO_FETCH=1 "$LANE_WORKTREES" "${gate_argv[@]}" --dry-run --porcelain 2>"$LE_TMP")" || gate_rc=$? + gate_err="$(cat -- "$LE_TMP" 2>/dev/null || :)" +else + gate_rc=127 + gate_err="there is no executable lane-worktrees at $LANE_WORKTREES; \`openRepoTools --install\` places it beside this command (or set LANE_WORKTREES)" +fi + +gate_holder=""; gate_refused=""; gate_lines=""; gate_named="" +gate_trees=0; gate_branches=0; gate_scratch=0; gate_caches=0; gate_residue=0; gate_foreign=0 +gate_person=0 # counted rows `--yes` leaves too: a person's to settle +gate_lf=' +' +while IFS="$TAB" read -r g_kind g_a g_b g_c g_d g_e g_f; do + [ -n "${g_kind:-}" ] || continue + # THE FIELDS ARE ESCAPED (`\\`, `\t`, `\n`, `\r`) so a path is one field + # whatever it holds, and `%b` is exactly the inverse: every backslash of the + # original arrives doubled, so no other escape can appear. + case "$g_kind" in + lane) gate_holder="${g_c:-}" ;; + refused) gate_refused="${g_a:-}" ;; + tree) + g_path="$(printf '%b' "${g_c:-}")" + # A FOREIGN ROW NAMES NOTHING FOR THE RESIDUE READ (S1 of the review of + # #175): it is counted for nobody, so a standalone clone under the lane's + # own root, which the sweep keeps FOREIGN, must still hold the gate. + case "${g_a:-}" in + foreign) gate_foreign=$((gate_foreign + 1)); continue ;; + esac + gate_named="${gate_named}$(gate_real "$g_path")${gate_lf}" + [ "${g_b:-}" = retire ] || continue + gate_trees=$((gate_trees + 1)) + case "${g_a:-}" in keep | live | load-bearing) gate_person=$((gate_person + 1)) ;; esac + g_head="${g_e:-?}" + gate_lines="${gate_lines} tree ${g_a} ${g_path} [${g_d:-?} ${g_head:0:12}]${gate_lf} $(gate_tree_state "$g_path")${gate_lf} ${g_f:-}${gate_lf}" ;; + branch) + [ "${g_b:-}" = retire ] || continue + gate_branches=$((gate_branches + 1)) + case "${g_a:-}" in keep | list) gate_person=$((gate_person + 1)) ;; esac + gate_lines="${gate_lines} branch ${g_a} $(printf '%b' "${g_c:-}") ${g_d:-}${gate_lf} ${g_f:-}${gate_lf}" ;; + scratch) + g_path="$(printf '%b' "${g_c:-}")" + gate_named="${gate_named}$(gate_real "$g_path")${gate_lf}" + gate_scratch=$((gate_scratch + 1)) + case "${g_a:-}" in keep | live | load-bearing) gate_person=$((gate_person + 1)) ;; esac + gate_lines="${gate_lines} scratch ${g_a} ${g_path} (${g_e:-0} bytes)${gate_lf} ${g_f:-}${gate_lf}" ;; + cache) + g_path="$(printf '%b' "${g_c:-}")" + gate_named="${gate_named}$(gate_real "$g_path")${gate_lf}" + gate_caches=$((gate_caches + 1)) + gate_lines="${gate_lines} cache ${g_a} ${g_path} (${g_e:-0} bytes)${gate_lf}" ;; + esac +done </dev/null)" || gd_rc=$? +gate_root="" +case "$gd_rc" in + 0) gd_dir="${gd_dir%/}" + if [ -n "$gd_dir" ]; then gate_root="${gd_dir%/*}/.lane-worktrees/$LANE"; fi ;; + 8) : ;; + *) gate_unread="\`$LANES_EDIT lane-dir $LANE\` exited $gd_rc, so the lane's own root could not be read for residue" ;; +esac +gate_is_named() { # + gin_p="${1-}" + while IFS= read -r gin_l; do + if [ -n "$gin_l" ] && [ "$gin_l" = "$gin_p" ]; then return 0; fi + done </dev/null 2>&1; then + gate_unread="${gate_unread:+$gate_unread; }the lane's own root $gate_root could not be listed, so what is in it is unknown" +elif [ -n "$gate_root" ] && [ -d "$gate_root" ]; then + for g_ent in "$gate_root"/* "$gate_root"/.[!.]* "$gate_root"/..?*; do + if [ ! -e "$g_ent" ] && [ ! -L "$g_ent" ]; then continue; fi + if gate_is_named "$(gate_real "$g_ent")"; then continue; fi + gate_residue=$((gate_residue + 1)) + if [ -L "$g_ent" ]; then + g_what="a symlink" + elif [ -d "$g_ent" ]; then + g_what="$(gate_tree_state "$g_ent")" + else + g_what="a file" + fi + gate_lines="${gate_lines} residue ${g_ent}${gate_lf} ${g_what}; under the lane's own root and neither a tree nor scratch the sweep knows${gate_lf}" + done +elif [ -n "$gate_root" ] && [ -e "$gate_root" ]; then + gate_unread="${gate_unread:+$gate_unread; }the lane's own root $gate_root is there and is no directory" +fi + +gate_left="" +gate_part() { # + if [ "${1:-0}" -gt 0 ]; then gate_left="${gate_left:+$gate_left and }$1 $2"; fi + return 0 +} +gate_part "$gate_trees" "tree(s)" +gate_part "$gate_branches" "branch(es)" +gate_part "$gate_scratch" "scratch dir(s)" +gate_part "$gate_caches" "cache(s)" +gate_part "$gate_residue" "residue path(s)" + +# THE VERDICT. 0 passes only with nothing named and nothing unread beside it; +# 3 and 2 never pass; anything else is the gate nobody read. +gate_pass=0; gate_exit=2; gate_log="" +case "$gate_rc" in + 0) if [ -z "$gate_left" ] && [ -z "$gate_unread" ]; then gate_pass=1; fi + gate_log="the close-out gate (sweep exit 0) found ${gate_left:-nothing} left${gate_unread:+ and could not read its own root for residue}" ;; + 3) gate_log="the close-out gate (sweep exit 3) found ${gate_left:-something} left" ;; + 2) gate_log="the close-out gate's sweep refused this lane (exit 2)${gate_left:+ with $gate_left left}" ;; + *) gate_exit=1 + gate_log="the close-out gate could not be read (lane-worktrees exit $gate_rc)" ;; +esac +# A ROOT THAT COULD NOT BE READ IS THE GATE UNREAD WHATEVER THE SWEEP ANSWERED +# (Copilot round 2 on #175): exit 1, the environment, on 3 and 2 as on 0. +if [ -n "$gate_unread" ]; then + gate_exit=1 + case "$gate_rc" in + 2 | 3) gate_log="$gate_log; and its own root could not be read for residue" ;; + esac +fi + +if [ "$gate_pass" = 1 ]; then + step "close-out gate: nothing of lane $LANE's is left on disk ($gate_shown --dry-run answered 0)" + if [ "$inventory_only" = 1 ]; then + note "--inventory-only changed nothing: the close-out gate passed, so the lane ends as any other does" + fi + inventory_only=0 +else + fflag=""; (( force )) && fflag=" --force" + if [ "$inventory_only" = 0 ]; then + case "$gate_rc" in + 0 | 3) + if [ -z "$gate_left" ] && [ -n "$gate_unread" ]; then + note "lane $LANE is NOT ended: the close-out gate (opensoft/openRepoTools#163) could not read all it asks about, and a read that failed is never an answer (Amendment 7(d))." + else + note "lane $LANE is NOT ended: the close-out gate (opensoft/openRepoTools#163) found what is still the lane's: ${gate_left:-the rows below}" + fi ;; + 2) note "lane $LANE is NOT ended: the close-out gate's sweep refused it (exit 2): ${gate_refused:-it printed no reason; run the table below}" ;; + *) note "lane $LANE is NOT ended: the close-out gate could not be read — \`$gate_shown --dry-run --porcelain\` exited $gate_rc, and a read that failed is never an answer (Amendment 7(d))." ;; + esac + if [ -n "$gate_lines" ]; then printf '%s' "$gate_lines" >&2; fi + if [ "$gate_foreign" -gt 0 ]; then + note "($gate_foreign FOREIGN tree(s) beside these are not this lane's, and were not counted.)" + fi + if [ -n "$gate_unread" ]; then note "and: $gate_unread"; fi + if [ "$gate_exit" = 1 ] && [ -n "$gate_err" ]; then + note "what the gate said on stderr:" + printf '%s\n' "$gate_err" | sed -e 's/^/ /' >&2 + fi + note "the table, which changes nothing:" + note " $gate_shown" + # THE ACT IS OFFERED ONLY WHERE THE SWEEP WOULD TAKE IT: on 2 its `--yes` + # refuses for the same reason its dry run did. + gate_act=0 + if [ "$gate_rc" = 3 ]; then gate_act=1; fi + if [ "$gate_rc" = 0 ] && [ "$gate_caches" -gt 0 ]; then gate_act=1; fi + if [ "$gate_act" = 1 ]; then + note "the one act that retires them (--yes removes only what is on origin or in a bundle, never a live writer's tree):" + if [ "$gate_holder" = live ]; then + note " $gate_shown --yes --live | --live none" + else + note " $gate_shown --yes" + fi + if [ "$gate_person" -gt 0 ]; then + note " ($gate_person row(s) above marked keep, live, load-bearing or list are left by --yes as well: each says why, and settling it is a person's)" + fi + fi + if [ "$gate_residue" -gt 0 ]; then + note "residue is no sweep's to retire: move it to \${XDG_STATE_HOME:-~/.local/state}/openRepoTools/lanes/$LANE/scratch/ or remove it by hand" + fi + note "or end the lane and leave them where they are, in its inventory, with the reason written down:" + note " $prog $LANE$rflag$fflag --inventory-only" + exit "$gate_exit" + fi + step "--inventory-only: ending lane $LANE past the close-out gate; $gate_log. Nothing on disk is touched, and the lines written say so" +fi + # --- BEGIN shared one-line cut (lane-collision-protocol Amendment 13(a)) --- # BYTE-IDENTICAL IN `lane-start`, `lane-end` AND `lane-handoff`, the three # commands that write a row's state cell — the same trade the four lane helpers @@ -1882,6 +2224,13 @@ if [ "$in_flight" = 1 ] && [ "$have_log" = 1 ]; then elif [ "$in_flight" = 1 ]; then state_line="lane-end on $ws: window closing; ENDED WITH --force while the state cell of $cell_source still showed an open $cell_word; read the archived cell before trusting it" fi +# THE DOOR PAST THE CLOSE-OUT GATE IS WRITTEN DOWN (#163): the row says it was +# taken and what the gate found, so an ended lane whose trees are still on disk +# is never read as one that closed out clean. It goes AFTER whatever the line +# already says, so a forced end keeps its own words first. +if [ "$inventory_only" = 1 ]; then + state_line="$state_line; ENDED WITH --inventory-only: $gate_log; they stay in its #97 inventory" +fi # The cell's own separator never appears INSIDE the line, and a line over # Amendment 13's cap is cut here rather than costing the row its whole closing # write — the object log one section along carries the full text either way. @@ -1929,6 +2278,12 @@ if [ "$in_flight" = 1 ] && [ "$have_log" = 1 ]; then elif [ "$in_flight" = 1 ]; then lane_text="forced; open: $cell_word in the state cell (pre-cutover lane, no object log)" fi +# AND THE LANE'S OWN LOG CARRIES THE REASON IN FULL, which the row's 240 +# characters may have cut (#163). No ` — ` and no `, ` in it: the line's own +# separators. +if [ "$inventory_only" = 1 ]; then + lane_text="$lane_text; inventory-only: $gate_log; $gate_shown names them" +fi if (( dry_run )); then plan "lanes-edit.sh log $lane_verb lane:$LANE --text \"$lane_text\"" diff --git a/lane-worktrees b/lane-worktrees index beb3ddc1..c2da8d88 100755 --- a/lane-worktrees +++ b/lane-worktrees @@ -12,6 +12,8 @@ abandoned worktrees, branches, scratch and killed-suite sandboxes lane-worktrees sweep --expire [--dry-run | --yes] [--porcelain] lane-worktrees sweep --all --dry-run --report [--post ] [--estate ] + lane-worktrees add [--branch ] [--from ] + [--checkout ] [--dry-run] lane-worktrees --help | --version A SWAP THAT DOES NOT FINISH GRACEFULLY LEAVES ITS WRITERS' TREES WHERE THEY @@ -90,6 +92,21 @@ backslash is written `\\`, a TAB `\t`, a newline `\n` and a carriage return `\r`, so a path holding any of them is still one field of one row. Undecodable bytes in a path pass through as the bytes they are. +ADD (`add `, #163) IS HOW A LANE MAKES A WORKTREE, AND IT +RECORDS THE TREE IN THE LANE'S #97 INVENTORY IN THE SAME ACT: a `git worktree +add` of the lane's checkout (or `--checkout `) at `/.lane-worktrees//`, on branch `--branch` (default: the +slice) - checked out where it exists, else made from `--from` (default HEAD) +with `--no-track`, so a bare `git push` never aims a writer's commits at the +branch it started from - then `lanes-edit.sh set-lane-tree`. A record that +fails, or a `git worktree add` that fails after making the tree (a failing +post-checkout hook), takes the fresh tree and its new branch back out. The +path is printed on +stdout; `--dry-run` makes nothing. A tree made any other way is FOREIGN until +`lanes-edit.sh set-lane-tree ` records it. Exit 0 made and +recorded, 2 refused (nothing made), 1 made and neither recorded nor taken back +out (the commands are printed), 64 usage. + Exit codes: 0 dry run: nothing to retire · --yes: every act completed 3 dry run: something to retire (the `lane-end` gate's answer, #163) @@ -2176,7 +2193,10 @@ class Sweep: t.checkout = rec.get("checkout") if rec.get("checkout") not in ("", "unknown") else "" seen[t.real] = t checkouts = [] - if self.facts.dir and os.path.isdir(self.facts.dir): + # A RECORDED DIRECTORY THAT IS NO REPOSITORY HAS NO REGISTRATIONS, as it + # has no branches (#163, `_branch_checkouts`): an absence, never the + # failed `git worktree list` that #170 G5 refuses. + if self.facts.dir and os.path.isdir(self.facts.dir) and not no_repository(self.facts.dir): checkouts.append(self.facts.dir) for t in list(seen.values()): if t.checkout and os.path.isdir(t.checkout) and t.checkout not in checkouts: @@ -2336,7 +2356,10 @@ class Sweep: def _branch_checkouts(self) -> list: checkouts = [] - if self.facts.dir and os.path.isdir(self.facts.dir): + # A RECORDED DIRECTORY THAT IS NO REPOSITORY HAS NO BRANCHES (#163): it + # is an absence, not a read that failed - and as a `keep` row it held + # `lane-end`'s gate shut for every lane started outside a checkout. + if self.facts.dir and os.path.isdir(self.facts.dir) and not no_repository(self.facts.dir): checkouts.append(self.facts.dir) for t in self.trees: if t.owner == "inventory" and t.checkout and os.path.isdir(t.checkout) \ @@ -4213,6 +4236,17 @@ def find_caches(root: str) -> list: return out +def no_repository(path: str) -> bool: + """True only where git answers that there is NO repository at `path` - + which has no branches - and never where one could not be read. A `.git` + entry of its own IS a repository, however broken: git answers "not a git + repository" for a gitdir it cannot read too (#170 G5).""" + if os.path.lexists(os.path.join(path, ".git")): + return False + proc = git(path, "rev-parse", "--git-dir") + return proc.returncode != 0 and "not a git repository" in (proc.stderr or "").lower() + + def branch_items(checkout: str, lane: str, evidence: MergeEvidence) -> list: """`--branches`: local branches NO worktree has checked out. Merged by PR evidence: deleted (and the remote where it is this lane's and holds @@ -5337,6 +5371,192 @@ def report_all(opts) -> int: return 0 +# ======================================================= add: the creation + +#: A slice is one path component under the lane's own root, and the default +#: branch name: what a ref and a directory both take. +SLICE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") + + +def add_tree(ns) -> int: + """`add ` - the one act that MAKES a lane's worktree, and it + records the tree in the lane's #97 inventory in the same act + (opensoft/openRepoTools#163). A tree made by hand is legal git and FOREIGN + to every sweep until something records it; lane openRepoTools-3's own + inventory was empty while it had nine trees, so the gate could not tell + which were its. Nothing in this tooling made a worktree before this verb, + so this is where creation and the record become one act. + + The tree goes under the lane's own root, `/ + .lane-worktrees//`, which only this lane's acts make, as a + `git worktree` of the lane's checkout (or `--checkout `). A new + branch is made with `--no-track`: a branch made from `origin/main` that + tracked it would push a writer's commits at main on a bare `git push`. + + Exit 0: made and recorded, the path on stdout. 2: refused, and nothing + was made - including a record that failed after the tree was made, or a + `git worktree add` that failed after making it (a failing `post-checkout` + hook), when the fresh tree and the branch this act made are taken back + out. 1: something it made is left and not recorded; the commands are + printed. 64: usage.""" + lane, slice_name = ns.lane or "", ns.slice or "" + usage = ("lane-worktrees add [--branch ] [--from ] " + "[--checkout ] [--dry-run]") + if not lane or not slice_name: + note(f"usage: {usage}") + return 64 + if not re.match(r"^[A-Za-z0-9][A-Za-z0-9._-]*$", lane): + note(f"usage: '{lane}' is not a lane name") + return 64 + if not SLICE_RE.match(slice_name) or ".." in slice_name or slice_name.endswith(".lock"): + note(f"usage: '{slice_name}' is not a slice name (letters, digits, . _ -; one path " + "component, which is also the default branch name)") + return 64 + if ns.checkout is not None and not os.path.isabs(ns.checkout): + note("usage: --checkout takes an absolute path") + return 64 + + def refuse(why: str) -> int: + note(f"REFUSED, nothing was made: {why}") + return 2 + + helper = Helper() + if not helper.path: + return refuse("no lanes-edit.sh beside this command or on PATH, and the record is its") + canon, why = helper.canon_lane(lane) + if why: + return refuse(f"lane {lane}'s name could not be resolved: {why}") + lane = canon + mproc = helper.call("managed-projection", lane) + if mproc.returncode == 0: + return refuse(f"lane {lane} is owned by the managed ledger (owner " + f"{mproc.stdout.strip() or 'unnamed'}), and its worktrees are that " + "ledger's (ruling 2026-10-04)") + if mproc.returncode != 8: + return refuse(f"whether the managed ledger owns lane {lane} is UNKNOWN (`lanes-edit.sh " + f"managed-projection {lane}` exited {mproc.returncode}); an ownership " + "nobody could read is never read as legacy (Amendment 7(d))") + dproc = helper.call("lane-dir", lane) + if dproc.returncode == 8: + return refuse(f"lane {lane} records no directory (Amendment 11(c)), so it has no " + "root to make a tree under; start it with lane-start first") + if dproc.returncode != 0 or not dproc.stdout.strip(): + return refuse(f"`lanes-edit.sh lane-dir {lane}` exited {dproc.returncode}: " + + _first_line(dproc.stderr)) + lane_dir = dproc.stdout.strip().rstrip("/") or "/" + checkout = (ns.checkout or lane_dir).rstrip("/") or "/" + top = git_out(checkout, "rev-parse", "--show-toplevel") + if top is None or real(top) != real(checkout): + return refuse(f"{checkout} is not the root of a git checkout" + + (f" ({top} is)" if top else "")) + # THE RECORD MUST HAVE SOMEWHERE TO GO BEFORE THE TREE IS MADE: a tree + # this act could not record is the very thing it exists to stop. + tproc = helper.call("lane-trees", lane) + if tproc.returncode not in (0, 8): + return refuse(f"lane {lane}'s inventory cannot be read or written (`lanes-edit.sh " + f"lane-trees {lane}` exited {tproc.returncode}: " + + _first_line(tproc.stderr) + ")") + root = os.path.join(os.path.dirname(lane_dir), ".lane-worktrees", lane) + path = os.path.join(root, slice_name) + if os.path.lexists(path): + return refuse(f"{path} already exists; a tree is made where nothing is, and one " + f"already there is recorded with: lanes-edit.sh set-lane-tree {lane} {path}") + branch = ns.branch or slice_name + if git(checkout, "check-ref-format", "--branch", branch).returncode != 0: + return refuse(f"'{branch}' is not a branch name git takes") + exists = git(checkout, "rev-parse", "-q", "--verify", f"refs/heads/{branch}").returncode == 0 + if exists and ns.from_ref: + return refuse(f"branch {branch} already exists, so --from {ns.from_ref} would be " + "ignored; name a new branch, or drop --from to check this one out") + start = ns.from_ref or "HEAD" + # THE COMMIT A NEW BRANCH STARTS AT, resolved ONCE and handed to git as an + # id: the take-back below deletes the branch only while it still points + # here, so a commit made on it meanwhile (a `post-checkout` hook can make + # one) is never made unreachable. + start_oid = "" + if not exists: + start_oid = git_out(checkout, "rev-parse", "-q", "--verify", f"{start}^{{commit}}") or "" + if not start_oid: + return refuse(f"'{start}' names no commit in {checkout}") + argv = (["worktree", "add", path, branch] if exists else + ["worktree", "add", "--no-track", "-b", branch, path, start_oid]) + if ns.dry_run: + note(f"DRY RUN, nothing was made: git -C {checkout} {' '.join(argv)}; then " + f"lanes-edit.sh set-lane-tree {lane} {path} --checkout {checkout}") + say(path) + return 0 + try: + os.makedirs(root, exist_ok=True) + except OSError as exc: + return refuse(f"the lane's root {root} could not be made: {exc}") + _ACTIVE["add"] = (lane, path) + + def take_back(what: str) -> int: + """Undo what this act made - the tree's registration and directory, and + the branch where this act made it - after `what` went wrong. 2 where + nothing is left, 1 where something is, with the commands printed.""" + left = [] + regs = git(checkout, "worktree", "list", "--porcelain") + registered = None + if regs.returncode == 0: + registered = any(real(line[len("worktree "):]) == real(path) + for line in regs.stdout.splitlines() if line.startswith("worktree ")) + if registered: + if git(checkout, "worktree", "remove", path).returncode != 0: + left.append(f"the tree {path} (git -C {checkout} worktree remove {path})") + elif registered is None: + left.append(f"whatever git registered at {path} (its registrations could not be read)") + elif os.path.lexists(path): + # MADE AND NOT REGISTERED: nothing here proves it is git's alone, + # so it is named and never deleted blind. + left.append(f"the directory {path}, which git does not register") + if not exists: + tip = git_out(checkout, "rev-parse", "-q", "--verify", f"refs/heads/{branch}") + if tip and tip != start_oid: + # IT MOVED PAST WHERE THIS ACT STARTED IT: the commits on it are + # somebody's, and a branch is their only name. + left.append(f"branch {branch}, which this act made at {short(start_oid)} and " + f"which now holds {short(tip)}: kept, so that commit stays reachable") + elif tip and (left or git(checkout, "update-ref", "-d", f"refs/heads/{branch}", + start_oid).returncode != 0): + left.append(f"branch {branch} at {short(tip)}, which this act made") + _ACTIVE.pop("add", None) + if not left: + note(f"REFUSED, nothing was made: {what}; the fresh tree {path} was removed " + "again" + ("" if exists else f" and branch {branch} with it")) + return 2 + note(f"{what}, and this is left of what this act made: {'; '.join(left)}. Record a " + f"tree that is wanted with: lanes-edit.sh set-lane-tree {lane} {path} " + f"--checkout {checkout}") + return 1 + + made = git(checkout, *argv) + if made.returncode != 0: + # A FAILED `worktree add` MAY HAVE MADE THE TREE ALREADY: a + # `post-checkout` hook runs after the checkout, and git returns its + # status. Whatever it left is taken back like a record that failed. + return take_back(f"git worktree add failed: {_first_line(made.stderr)}") + head = git_out(path, "rev-parse", "HEAD") or "" + caller = os.environ.get("LANES_SESSION") or os.environ.get("CLAUDE_CODE_SESSION_ID") or "" + record = ["set-lane-tree", lane, path, "--checkout", checkout] + if UUID_RE.match(caller): + record += ["--writer", caller] + rproc = helper.call(*record) + if rproc.returncode == 0: + note(f"made {path} on branch {branch} at {short(head)} in {checkout}; recorded in lane " + f"{lane}'s inventory as {rproc.stdout.strip() or '?'}") + _ACTIVE.pop("add", None) + say(path) + return 0 + # THE RECORD FAILED: the fresh tree and the branch this act made are taken + # back out, so a tree nothing records is not left behind by the act whose + # whole point is that there are none. `worktree remove` without --force: + # nothing has been written in it yet, and git still refuses if it was. + return take_back(f"the tree was made and could NOT be recorded (`lanes-edit.sh " + f"{' '.join(record)}` exited {rproc.returncode}: " + f"{_first_line(rproc.stderr)})") + + # ==================================================================== main class _Parser(argparse.ArgumentParser): @@ -5348,6 +5568,10 @@ def parse(argv: list): parser = _Parser(prog=PROG, add_help=False) parser.add_argument("verb", nargs="?") parser.add_argument("lane", nargs="?") + parser.add_argument("slice", nargs="?") # add's + parser.add_argument("--branch") # add's + parser.add_argument("--from", dest="from_ref") # add's + parser.add_argument("--checkout") # add's mode = parser.add_mutually_exclusive_group() mode.add_argument("--dry-run", action="store_true") mode.add_argument("--yes", action="store_true") @@ -5391,6 +5615,13 @@ def main(argv: list) -> int: if os.path.basename(f.filename) == os.path.basename(__file__)] where = f" at {PROG} line {frames[-1].lineno}" if frames else "" detail = f"{type(exc).__name__}: {exc}{where}" + added = _ACTIVE.get("add") + if added: + # `add` MADE A TREE BEFORE THE ERROR: "nothing was changed" would + # be false, and the tree may be one nothing records. + note(f"INTERNAL ERROR after the tree {added[1]} was made: {detail}; whether it is " + f"recorded is in: lanes-edit.sh lane-trees {added[0]}") + return 1 sweep = _ACTIVE.get("sweep") acted = bool(sweep is not None and sweep.acting) if sweep is not None and sweep.opts.porcelain: @@ -5420,8 +5651,31 @@ def _main(argv: list) -> int: if ns.version: say(f"{PROG} {VERSION}") return 0 + if ns.verb == "add": + # THE SWEEP'S WORDS ARE NOT ADD'S: a flag a verb does not read is a + # line that does not do what it says. + theirs = [flag for flag, given in ( + ("--yes", ns.yes), ("--porcelain", ns.porcelain), ("--bundle", ns.bundle), + ("--branches", ns.branches), ("--links", ns.links), + ("--push-untracked", ns.push_untracked), + ("--include-scratch", ns.include_scratch), ("--include-caches", ns.include_caches), + ("--include-sandboxes", ns.include_sandboxes), + ("--include-foreign", ns.include_foreign), ("--word", ns.word is not None), + ("--live", bool(ns.live)), ("--fetch", ns.fetch), ("--estate", ns.estate is not None), + ("--expire", ns.expire), ("--all", ns.all), ("--report", ns.report), + ("--post", ns.post is not None)) if given] + if theirs: + note(f"usage: {', '.join(theirs)} belong to sweep, not to add") + return 64 + return add_tree(ns) if ns.verb != "sweep": - note("usage: lane-worktrees sweep [--dry-run | --yes] ... (see --help)") + note("usage: lane-worktrees sweep [--dry-run | --yes] ... | lane-worktrees " + "add ... (see --help)") + return 64 + if ns.slice is not None or ns.branch is not None or ns.from_ref is not None \ + or ns.checkout is not None: + note("usage: a slice, --branch, --from and --checkout are add's, and sweep takes " + "none of them") return 64 if ns.all or ns.report or ns.post is not None: if not (ns.all and ns.report) or ns.lane or ns.yes or ns.expire: diff --git a/openRepoTools b/openRepoTools index 1af8831e..9b7cbd2b 100755 --- a/openRepoTools +++ b/openRepoTools @@ -131,7 +131,9 @@ sweep: printed. --restart is /ctx; --exit ends the session a second place asked for the lane lane-end close it, and refuse while anything is in - flight + flight or any tree, branch, scratch or cache + of it is still on disk (--inventory-only + ends it anyway, and says so) lane-rename rename it: the row, the object log, the handoff and lanes/aliases.tsv in ONE commit, one comment per object it holds, and the old @@ -158,6 +160,9 @@ sweep: run by default; --yes pushes or rescues first and removes only what is on origin or in a bundle, never a live writer's + lane-worktrees add + make a lane's worktree and record it in the + lane's inventory, in one act `--install` also places THREE SKILLS at two paths each — `~/.claude-profiles/shared/skills//SKILL.md` and diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index de6c71db..2d4287e9 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -49,7 +49,7 @@ TESTS_DIR="$(cd -- "$(dirname -- "$SELF")" && pwd)" # the root of `opensoft/openRepoTools` now and this suite is in `tests/`. SRC_DIR="$(cd -- "$TESTS_DIR/.." && pwd)" -for f in lane-start lane-end lanes-edit.sh link-estates lane lanes lane-rename; do +for f in lane-start lane-end lanes-edit.sh link-estates lane lanes lane-rename lane-worktrees; do [ -x "$SRC_DIR/$f" ] || { echo "missing or not executable: $SRC_DIR/$f" >&2; exit 1; } done [ -f "$SRC_DIR/repos.tsv" ] || { echo "missing: $SRC_DIR/repos.tsv" >&2; exit 1; } @@ -489,11 +489,13 @@ export OPENREPOTOOLS_BIN_DIR="$SANDBOX/bin" mkdir -p "$OPENREPOTOOLS_BIN_DIR" cp -p "$SRC_DIR/lane-start" "$SRC_DIR/lane-end" "$SRC_DIR/lanes-edit.sh" \ "$SRC_DIR/link-estates" "$SRC_DIR/lane" "$SRC_DIR/lanes" \ - "$SRC_DIR/lane-rename" "$SRC_DIR/repos.tsv" "$OPENREPOTOOLS_BIN_DIR/" + "$SRC_DIR/lane-rename" "$SRC_DIR/repos.tsv" "$SRC_DIR/lane-worktrees" \ + "$OPENREPOTOOLS_BIN_DIR/" chmod 755 "$OPENREPOTOOLS_BIN_DIR"/lane-start "$OPENREPOTOOLS_BIN_DIR"/lane-end \ "$OPENREPOTOOLS_BIN_DIR"/lanes-edit.sh "$OPENREPOTOOLS_BIN_DIR"/link-estates \ "$OPENREPOTOOLS_BIN_DIR"/lane "$OPENREPOTOOLS_BIN_DIR"/lanes \ - "$OPENREPOTOOLS_BIN_DIR"/lane-rename "$OPENREPOTOOLS_BIN_DIR"/repos.tsv + "$OPENREPOTOOLS_BIN_DIR"/lane-rename "$OPENREPOTOOLS_BIN_DIR"/repos.tsv \ + "$OPENREPOTOOLS_BIN_DIR"/lane-worktrees # AFTER the fake bin, which must still win for `tmux` and `claude`. export PATH="$SANDBOX/fakebin:$OPENREPOTOOLS_BIN_DIR:$PATH" @@ -14628,7 +14630,12 @@ is "lanes-edit.sh claim with the seam unset exits 0" "$rc" 0 has "…and stamps the same host on the CLAIMED line it writes" \ "$(grep '^CLAIMED' "$LOGD/repoWS-1.md" | tail -n1)" "@$WS_HOST," -run env -u LANES_WORKSTATION LANES_IN_CONTAINER=0 "$END" repoWS-1 --force +# `--inventory-only` BESIDE `--force` (#163): this case is about the workstation +# name, and since the close-out gate a lane-end asks #162's sweep first — which +# refuses while ANY lane's inventory under the same control root cannot be read, +# and the #91 section above leaves dangling sidecars there on purpose. The door +# takes a gate that is not this case's subject out of it, and says so in the row. +run env -u LANES_WORKSTATION LANES_IN_CONTAINER=0 "$END" repoWS-1 --force --inventory-only is "lane-end with the seam unset exits 0" "$rc" 0 has "…and its row status names the same host" \ "$(grep '^| `repoWS-1`' "$LANES")" "lane-end on $WS_HOST:" @@ -15637,6 +15644,353 @@ rm -rf "$WIP/handoffs/sandbox" if [ -n "$B162_IGN" ]; then printf '%s\n' "$B162_IGN" > "$WIP/.gitignore"; else rm -f "$WIP/.gitignore"; fi git -C "$WIP" checkout -q -- .gitignore 2>/dev/null || : +# ========================================================================= +echo "== opensoft/openRepoTools#163: lane-end's close-out gate, and lane-worktrees add ==" +# +# WORKSPACE CREATION OUTPACES WORKSPACE CLOSEOUT, and #163 is both ends of it. +# `lane-worktrees add` makes a lane's worktree AND records it in the lane's #97 +# inventory in one act, so no tree the tooling makes is ever unrecorded; and +# `lane-end` asks #162's sweep, as a dry run, before it ends a lane, and refuses +# while anything of the lane's is still on disk — naming it, and the sweep that +# retires it — unless `--inventory-only` is the word, which ends the lane and +# writes down why. EVERYTHING THIS SECTION USES IS ITS OWN: its own repository +# and bare origin, its own lanes (`repoCG-*`), its own `.lane-worktrees` roots +# and its own lifecycle control root, all under the suite's sandbox and BESIDE +# `$HOME/projects` rather than in it. That is not tidiness: the sweep reads +# every OTHER lane's inventory under the same control root before it judges a +# tree, and refuses where one cannot be read (#162) — and the #91 section above +# leaves dangling sidecars there on purpose. A gate asked under that root would +# answer for those fixtures, not for these lanes. The control root this section +# exports is put back at its end. +CG_E="$OPENREPOTOOLS_BIN_DIR/lanes-edit.sh" +CG_LW="$OPENREPOTOOLS_BIN_DIR/lane-worktrees" +CG_ID="16300001-1111-4000-8000-163000011111" +CG_EST="$SANDBOX/cgp" +CG_DIR="$CG_EST/repoCG" +CG_ORIGIN="$SANDBOX/repoCG-origin.git" +CG_ROOT="$CG_EST/.lane-worktrees" +CG_STATE="$SANDBOX/cg-state" +CG_LOGD="$WIP/lanes/log" +cg_had_lsr="${LANES_LANE_STATE_ROOT+set}"; cg_lsr="${LANES_LANE_STATE_ROOT-}" +export LANES_LANE_STATE_ROOT="$CG_STATE" +mkdir -p "$CG_LOGD" "$CG_EST" "$CG_STATE" +git init -q --bare -b main "$CG_ORIGIN" +git init -q -b main "$CG_DIR" +git -C "$CG_DIR" config user.email "test@example.invalid" +git -C "$CG_DIR" config user.name "lane helper tests" +git -C "$CG_DIR" remote add origin "$CG_ORIGIN" +printf '.env\nnode_modules/\n' > "$CG_DIR/.gitignore" +printf 'seed\n' > "$CG_DIR/a.txt" +git -C "$CG_DIR" add -A >/dev/null 2>&1 +git -C "$CG_DIR" commit -q -m "the close-out estate's first commit" +git -C "$CG_DIR" push -q origin main 2>/dev/null + +cg_lane() { # [] — a row, and a log whose STARTED line records the lane's directory + "$CG_E" add-row "| \`$1\` | harness \`$CG_ID\` | Eagle / test / brett | 2026-10-06T00:00Z | none | handoffs/repoCG/$1.md | ACTIVE |" >/dev/null 2>&1 + { printf '# lane %s — object log (lane-collision-protocol Amendment 7)\n' "$1" + printf 'STARTED — lane %s, session %s@Eagle, 2026-10-06T00:00:00Z, lane:%s → home opensoft/repoCG; dir %s; profile team-01a\n' \ + "$1" "$CG_ID" "$1" "${2:-$CG_DIR}" + } > "$CG_LOGD/$1.md" + git -C "$WIP" add -- "lanes/log/$1.md" + git -C "$WIP" commit -q -m "LOG($1@Eagle): seed" + git -C "$WIP" pull -q --rebase origin main 2>/dev/null || : + git -C "$WIP" push -q origin main 2>/dev/null + return 0 +} +for cg_l in repoCG-1 repoCG-2 repoCG-3 repoCG-4 repoCG-5 repoCG-6 repoCG-7 repoCG-8 repoCG-11 repoCG-12 repoCG-13; do cg_lane "$cg_l"; done +# repoCG-10 was started in a directory that is NO repository: it has no branches, +# which is an absence and not a read that failed. +mkdir -p "$CG_EST/plain" +cg_lane repoCG-10 "$CG_EST/plain" +cg_row() { grep "^| \`$1\`" "$LANES"; } +cg_last() { tail -n 1 "$CG_LOGD/$1.md"; } + +# ------------------------------------- lane-worktrees add: made AND recorded + +CG_W1="$CG_ROOT/repoCG-2/w1" +run "$CG_LW" add repoCG-2 w1 --dry-run +is "lane-worktrees add --dry-run exits 0" "$rc" 0 +is "…printing the path the tree WOULD have, under the lane's own root" "$out" "$CG_W1" +is "…and makes nothing" "$( [ -e "$CG_W1" ] && echo made || echo none )" none + +run "$CG_LW" add repoCG-2 w1 +is "lane-worktrees add makes the lane's worktree" "$rc" 0 +is "…printing its path on stdout" "$out" "$CG_W1" +is "…as a git worktree of the lane's checkout" "$(git -C "$CG_DIR" worktree list --porcelain | grep -c '/repoCG-2/w1$' || :)" 1 +is "…on a branch named for the slice" "$(git -C "$CG_W1" symbolic-ref -q --short HEAD)" w1 +run "$CG_E" lane-trees repoCG-2 +is "…and RECORDS it in the lane's #97 inventory in the same act" "$rc" 0 +is "…by its path" "$(printf '%s\n' "$out" | awk -F'\037' 'NR == 1 { print $2 }')" "$CG_W1" +is "…its branch" "$(printf '%s\n' "$out" | awk -F'\037' 'NR == 1 { print $3 }')" w1 +is "…and its checkout" "$(printf '%s\n' "$out" | awk -F'\037' 'NR == 1 { print $10 }')" "$CG_DIR" + +CG_W2="$CG_ROOT/repoCG-2/w2" +run "$CG_LW" add repoCG-2 w2 --branch feat/cg-w2 --from origin/main +is "add --branch --from makes a new branch from that start" "$rc" 0 +is "…named as asked" "$(git -C "$CG_W2" symbolic-ref -q --short HEAD)" feat/cg-w2 +is "…and TRACKING NOTHING, so a bare git push never aims a writer's commits at main" \ + "$(git -C "$CG_W2" rev-parse --abbrev-ref '@{u}' >/dev/null 2>&1 && echo tracks || echo none)" none +is "…and recorded too" "$("$CG_E" lane-trees repoCG-2 2>/dev/null | awk -F'\037' -v p="$CG_W2" '$2 == p { n++ } END { print n + 0 }')" 1 + +run "$CG_LW" add repoCG-2 w1 +is "add refuses a path that is already there" "$rc" 2 +has "…naming the record a tree already there takes instead" "$err" "set-lane-tree repoCG-2" +run "$CG_LW" add repoCG-9 w1 +is "add refuses a lane that records no directory" "$rc" 2 +is "…and makes nothing" "$( [ -e "$CG_ROOT/repoCG-9" ] && echo made || echo none )" none +run "$CG_LW" add repoCG-2 'not/one' +is "a slice that is not one path component is usage" "$rc" 64 +has "…naming what a slice is" "$err" "is not a slice name" +run "$CG_LW" add repoCG-2 w3 --porcelain +is "…and so is a word that is the sweep's" "$rc" 64 +has "…saying whose word it is" "$err" "belong to sweep, not to add" +run "$CG_LW" add repoCG-2 w3 --push-untracked +is "…--push-untracked, #174's word for the sweep, included" "$rc" 64 +has "…named as the sweep's" "$err" "--push-untracked belong to sweep, not to add" + +if [ "$(id -u)" = 0 ]; then + skip "a record that fails takes the fresh tree back out" "root writes through a read-only directory, so the record cannot be made to fail here" +else + mkdir -p "$CG_STATE/repoCG-4/trees" + chmod 555 "$CG_STATE/repoCG-4/trees" + run "$CG_LW" add repoCG-4 w9 + chmod 755 "$CG_STATE/repoCG-4/trees" + is "a record that fails after the tree is made refuses" "$rc" 2 + has "…saying the fresh tree was taken back out" "$err" "removed again" + is "…so no unrecorded tree is left" "$( [ -e "$CG_ROOT/repoCG-4/w9" ] && echo left || echo none )" none + is "…and no branch the act made" "$(git -C "$CG_DIR" rev-parse -q --verify refs/heads/w9 >/dev/null 2>&1 && echo left || echo none)" none +fi + +# A `worktree add` THAT FAILS AFTER MAKING THE TREE (Copilot round 1 on #175): a +# failing `post-checkout` hook runs after the checkout, and git returns its +# status with the tree registered and the branch made. Both are taken back. +printf '#!/bin/sh\nexit 1\n' > "$CG_DIR/.git/hooks/post-checkout" +chmod +x "$CG_DIR/.git/hooks/post-checkout" +run "$CG_LW" add repoCG-12 w8 +rm -f "$CG_DIR/.git/hooks/post-checkout" +is "a worktree add that fails after making the tree refuses" "$rc" 2 +has "…saying what failed" "$err" "git worktree add failed" +has "…and that the fresh tree was taken back out" "$err" "removed again" +is "…so no unrecorded tree is left on disk" "$( [ -e "$CG_ROOT/repoCG-12/w8" ] && echo left || echo none )" none +is "…or registered" "$(git -C "$CG_DIR" worktree list --porcelain | grep -c '/repoCG-12/w8$' || :)" 0 +is "…and no branch the act made" "$(git -C "$CG_DIR" rev-parse -q --verify refs/heads/w8 >/dev/null 2>&1 && echo left || echo none)" none + +# AND A COMMIT MADE ON THE NEW BRANCH MEANWHILE IS NEVER MADE UNREACHABLE +# (Copilot round 2 on #175): the hook commits, then fails; the take-back removes +# the clean tree and KEEPS the branch, which now holds that commit. +printf '#!/bin/sh\ngit commit -q --allow-empty -m "made by the hook"\nexit 1\n' > "$CG_DIR/.git/hooks/post-checkout" +chmod +x "$CG_DIR/.git/hooks/post-checkout" +run "$CG_LW" add repoCG-12 w7 +rm -f "$CG_DIR/.git/hooks/post-checkout" +is "a worktree add whose hook committed and failed leaves something, so it is exit 1" "$rc" 1 +has "…naming the branch it kept" "$err" "branch w7, which this act made at" +is "…which still holds the hook's commit" "$(git -C "$CG_DIR" log -1 --format=%s refs/heads/w7 2>/dev/null)" "made by the hook" +is "…while the clean tree itself was taken back out" "$( [ -e "$CG_ROOT/repoCG-12/w7" ] && echo left || echo none )" none +git -C "$CG_DIR" branch -q -D w7 2>/dev/null || : + +# ---------------------------------------------- lane-end: the gate passes + +# OTHER LANES' TREES IN THE SAME CHECKOUT ARE NOT THIS LANE'S: repoCG-2's two +# are registered in repoCG's checkout, and repoCG-1 ends past them. +run "$END" repoCG-1 +is "a lane with nothing of its own on disk ends as it always has" "$rc" 0 +has "…saying the gate asked" "$err" "close-out gate: nothing of lane repoCG-1's is left on disk" +has "…and the closing phrase is the ordinary one" "$(cg_row repoCG-1)" "window closing; NOTHING IN FLIGHT" +hasnt "…with no door taken" "$(cg_row repoCG-1)" "inventory-only" + +run "$END" repoCG-10 +is "a lane started outside any repository ends: no repository is no branch, not a read that failed" "$rc" 0 +hasnt "…and its gate names no branch it could not read" "$err" "the branches could not be read" + +# A RECORDED DIRECTORY GIT CANNOT READ IS NOT ONE THAT HOLDS NO REPOSITORY (lane +# 2's and lane 3's reviews of #175 at 49bfa40). git answers "not a git +# repository" for a `.git` it cannot use as well as for none, so only a +# directory with NO `.git` entry of its own is an absence; any other is #170 +# G5's failed read, and the gate refuses it. +if [ "$(id -u)" = 0 ]; then + skip "a lane checkout whose .git is mode 000 refuses" "root reads a mode-000 directory" +else + git clone -q "$CG_ORIGIN" "$CG_EST/cg14" >/dev/null 2>&1 + git -C "$CG_EST/cg14" config user.email "test@example.invalid" + git -C "$CG_EST/cg14" config user.name "lane helper tests" + git -C "$CG_EST/cg14" checkout -q -b cg14-work + git -C "$CG_EST/cg14" commit -q --allow-empty -m "lane fourteen's own work + +Lane: repoCG-14" + cg_lane repoCG-14 "$CG_EST/cg14" + chmod 000 "$CG_EST/cg14/.git" + run "$CG_LW" sweep repoCG-14 --branches --include-scratch --include-caches --dry-run --porcelain + cg14_rc="$rc"; cg14_out="$out" + run "$END" repoCG-14 --dry-run + chmod 755 "$CG_EST/cg14/.git" + is "a lane checkout whose .git is mode 000, an unpublished branch of the lane's in it, is a read that failed: the sweep refuses (2)" "$cg14_rc" 2 + has "…naming the registrations it could not read" "$cg14_out" "the worktree registrations of $CG_EST/cg14 could not be read" + is "…and lane-end --dry-run refuses on it" "$rc" 2 + has "…relaying that reason" "$err" "the worktree registrations of $CG_EST/cg14 could not be read" +fi +mkdir -p "$CG_EST/cg15" +printf 'gitdir: %s\n' "$SANDBOX/cg15-nowhere/.git" > "$CG_EST/cg15/.git" +printf 'notes\n' > "$CG_EST/cg15/notes.txt" +cg_lane repoCG-15 "$CG_EST/cg15" +run "$CG_LW" sweep repoCG-15 --branches --include-scratch --include-caches --dry-run --porcelain +is "a lane directory whose .git file points nowhere refuses as well (2)" "$rc" 2 +has "…for the same reason" "$out" "the worktree registrations of $CG_EST/cg15 could not be read" +mkdir -p "$CG_EST/cg16" +printf 'notes\n' > "$CG_EST/cg16/notes.txt" +cg_lane repoCG-16 "$CG_EST/cg16" +run "$CG_LW" sweep repoCG-16 --branches --include-scratch --include-caches --dry-run --porcelain +is "while a directory with no .git entry at all holds no repository: the sweep answers 0" "$rc" 0 +hasnt "…and says no registration read failed" "$out" "could not be read" + +# ----------------------------------- lane-end: the gate refuses, and names it + +printf 'SECRET=1\n' > "$CG_W1/.env" +printf 'work\n' > "$CG_W2/scratch.txt" +mkdir -p "$CG_ROOT/repoCG-2/notes-scratch" +printf 'notes\n' > "$CG_ROOT/repoCG-2/notes-scratch/n.md" +cg2_row="$(cg_row repoCG-2)"; cg2_log="$(cksum < "$CG_LOGD/repoCG-2.md")" +run "$END" repoCG-2 +is "lane-end REFUSES a lane whose trees are still on disk (the sweep answers 3)" "$rc" 2 +has "…saying it is not ended" "$err" "lane repoCG-2 is NOT ended" +has "…naming the first tree" "$err" "$CG_W1" +has "…and the second" "$err" "$CG_W2" +has "…reading each with git status --ignored, so an ignored .env is named and the tree is not called clean" "$err" "1 ignored (.env)" +has "…and untracked work" "$err" "1 untracked" +has "…naming the lane's scratch" "$err" "$CG_ROOT/repoCG-2/notes-scratch" +has "…the exact table that lists them" "$err" "lane-worktrees sweep repoCG-2 --branches --include-scratch --include-caches" +has "…the act that retires them" "$err" "--include-caches --yes" +has "…and what that act removes, now that --yes is on" "$err" "removes only what is on origin or in a bundle" +has "…and the one door past it" "$err" "lane-end repoCG-2 --inventory-only" +is "…and writes NOTHING: the row is as it was" "$(cg_row repoCG-2)" "$cg2_row" +is "…and the lane's log is as it was" "$(cksum < "$CG_LOGD/repoCG-2.md")" "$cg2_log" + +run "$END" repoCG-2 --dry-run +is "the gate is asked on a dry run too" "$rc" 2 + +run "$END" repoCG-2 --sweep +is "--sweep is refused" "$rc" 2 +has "…by name, as an act that is not built" "$err" "--sweep is not built" + +run "$END" repoCG-2 --inventory-only +is "--inventory-only ends the lane past the gate" "$rc" 0 +has "…and the row says the door was taken" "$(cg_row repoCG-2)" "ENDED WITH --inventory-only" +has "…and what the gate found" "$(cg_row repoCG-2)" "sweep exit 3" +has "…while the lane's own ENDED line LOGS the reason it was let past" "$(cg_last repoCG-2)" "inventory-only: the close-out gate (sweep exit 3) found 2 tree(s) and 1 scratch dir(s) left" +is "…it touches nothing on disk: the tree is still there" "$( [ -f "$CG_W1/.env" ] && echo there || echo gone )" there +is "…and still in the lane's inventory" "$("$CG_E" lane-trees repoCG-2 2>/dev/null | awk -F'\037' -v p="$CG_W1" '$2 == p { n++ } END { print n + 0 }')" 1 + +# --------------------------------------- the sweep refusing is a refusal too + +git -C "$CG_DIR" worktree add -q -b cg3 "$CG_ROOT/repoCG-3/hand" >/dev/null 2>&1 +run "$END" repoCG-3 +is "a lane the SWEEP refuses (2: trees and no #97 snapshot) is not ended" "$rc" 2 +has "…relaying the sweep's own reason" "$err" "no #97 snapshot" +has "…naming the tree it would not judge" "$err" "$CG_ROOT/repoCG-3/hand" +hasnt "…and offering no --yes the sweep would refuse in the same words" "$err" "--yes" + +# ------------------------- residue: what the sweep's rows never reach, counted + +mkdir -p "$CG_ROOT/repoCG-5/leftover" +printf 'kept by nobody\n' > "$CG_ROOT/repoCG-5/leftover/notes.txt" +run "$END" repoCG-5 +is "residue under the lane's own root refuses although the sweep answered 0" "$rc" 2 +has "…named as residue" "$err" "residue $CG_ROOT/repoCG-5/leftover" +has "…with the place such things go instead" "$err" "openRepoTools/lanes/repoCG-5/scratch/" +rm -rf "$CG_ROOT/repoCG-5" +run "$END" repoCG-5 +is "…and once it is gone the lane ends" "$rc" 0 + +mkdir -p "$CG_ROOT/repoCG-8/node_modules/pkg" +printf 'x\n' > "$CG_ROOT/repoCG-8/node_modules/pkg/i.js" +run "$END" repoCG-8 +is "a cache under the lane's own root refuses too" "$rc" 2 +has "…named as a cache" "$err" "cache remove $CG_ROOT/repoCG-8/node_modules" + +# A ROOT NOBODY MAY LIST IS NOT AN EMPTY ONE (Copilot round 1 on #175). +if [ "$(id -u)" = 0 ]; then + skip "a lane root that cannot be listed is the gate unread" "root lists a directory whatever its mode" +else + mkdir -p "$CG_ROOT/repoCG-11/kept" + chmod 000 "$CG_ROOT/repoCG-11" + cg11_row="$(cg_row repoCG-11)" + run "$END" repoCG-11 + is "a lane root that cannot be listed is the gate unread (1), never an empty root" "$rc" 1 + has "…saying so" "$err" "could not be listed" + is "…and nothing is written" "$(cg_row repoCG-11)" "$cg11_row" + # …AND IT IS 1 WHATEVER THE SWEEP ANSWERED (Copilot round 2 on #175): here the + # sweep answers 3, for an unpublished branch of the lane's own. + git -C "$CG_DIR" branch -q cg11-old main + git -C "$CG_DIR" worktree add -q "$SANDBOX/cg11-tmp" cg11-old >/dev/null 2>&1 + git -C "$SANDBOX/cg11-tmp" commit -q --allow-empty -m "lane eleven's own work + +Lane: repoCG-11" + git -C "$CG_DIR" worktree remove "$SANDBOX/cg11-tmp" >/dev/null 2>&1 + run "$END" repoCG-11 + chmod 755 "$CG_ROOT/repoCG-11" + is "…and still 1 where the sweep answered 3: an unread root is the environment on every answer" "$rc" 1 + has "…naming the branch the sweep did find as well" "$err" "cg11-old" + git -C "$CG_DIR" branch -q -D cg11-old 2>/dev/null || : +fi + +# A STANDALONE CLONE UNDER THE LANE'S OWN ROOT IS NOT "NOTHING LEFT" (S1 of the +# review of #175). The sweep keeps it FOREIGN — a lane creates worktrees, never +# clones — and a FOREIGN row is counted for nobody, so it names nothing for the +# residue read either: the clone, unpushed commit and all, is residue. +git clone -q "$CG_ORIGIN" "$CG_ROOT/repoCG-13/clone" >/dev/null 2>&1 +git -C "$CG_ROOT/repoCG-13/clone" config user.email "test@example.invalid" +git -C "$CG_ROOT/repoCG-13/clone" config user.name "lane helper tests" +printf 'thirteen\n' > "$CG_ROOT/repoCG-13/clone/thirteen.txt" +git -C "$CG_ROOT/repoCG-13/clone" add thirteen.txt +git -C "$CG_ROOT/repoCG-13/clone" commit -q -m "work in a clone, never pushed" +cg13_row="$(cg_row repoCG-13)" +run "$END" repoCG-13 +is "a standalone clone with an unpushed commit under the lane's own root refuses although the sweep answered 0" "$rc" 2 +has "…named as residue, because its FOREIGN row names nothing" "$err" "residue $CG_ROOT/repoCG-13/clone" +hasnt "…and never passed as nothing left on disk" "$err" "nothing of lane repoCG-13's is left on disk" +is "…and nothing is written: the row is as it was" "$(cg_row repoCG-13)" "$cg13_row" +hasnt "…so it never says NOTHING IN FLIGHT" "$(cg_row repoCG-13)" "NOTHING IN FLIGHT" + +# --------------------------------- a branch of the lane's own, with no tree + +git -C "$CG_DIR" branch -q cg6-old main +git -C "$CG_DIR" worktree add -q "$SANDBOX/cg6-tmp" cg6-old >/dev/null 2>&1 +printf 'six\n' > "$SANDBOX/cg6-tmp/six.txt" +git -C "$SANDBOX/cg6-tmp" add six.txt +git -C "$SANDBOX/cg6-tmp" commit -q -m "lane six's own work + +Lane: repoCG-6" +git -C "$CG_DIR" worktree remove "$SANDBOX/cg6-tmp" >/dev/null 2>&1 +run "$END" repoCG-6 +is "a local branch the lane made, unpublished and held by no tree, refuses" "$rc" 2 +has "…naming the branch" "$err" "cg6-old" +has "…and saying --yes leaves a listed branch to a person as well" "$err" "left by --yes as well" + +# ------------------------------- a gate nobody could read is never a pass + +printf '#!/usr/bin/env bash\nexit 7\n' > "$SANDBOX/cg-broken-sweep" +chmod +x "$SANDBOX/cg-broken-sweep" +cg7_row="$(cg_row repoCG-7)" +run env LANE_WORKTREES="$SANDBOX/cg-broken-sweep" "$END" repoCG-7 +is "a sweep exit that is none of 0, 3 and 2 refuses as the environment (1)" "$rc" 1 +has "…saying the gate could not be read" "$err" "could not be read" +is "…and writes nothing" "$(cg_row repoCG-7)" "$cg7_row" +run env LANE_WORKTREES="$SANDBOX/cg-no-such-sweep" "$END" repoCG-7 +is "no lane-worktrees at all is the same refusal" "$rc" 1 +run env LANE_WORKTREES="$SANDBOX/cg-broken-sweep" "$END" repoCG-7 --inventory-only +is "--inventory-only ends it past a gate nobody read" "$rc" 0 +has "…and says so in the row" "$(cg_row repoCG-7)" "could not be read" + +# --------------------------------- the door belongs to a lane's ending only + +run "$END" repoCG-1 --retire 999999 --inventory-only +is "--inventory-only with a fork's --retire is refused" "$rc" 2 +has "…by name" "$err" "--inventory-only is the door past a lane's close-out gate" +run "$END" --retire-dormant repoCG --inventory-only +is "…and with the dormant sweep" "$rc" 2 +has "…by name" "$err" "--inventory-only is the door past ONE lane's close-out gate" + +if [ -n "$cg_had_lsr" ]; then export LANES_LANE_STATE_ROOT="$cg_lsr"; else unset LANES_LANE_STATE_ROOT; fi + # ------------------------------------------------------- nothing real touched # # The check is that the suite CREATED nothing here, which is not the same as diff --git a/tests/test_lane_worktrees.py b/tests/test_lane_worktrees.py index faf7e23c..910c1c80 100644 --- a/tests/test_lane_worktrees.py +++ b/tests/test_lane_worktrees.py @@ -1307,6 +1307,45 @@ def test_an_unreadable_pytest_sandbox_root_is_a_row_not_a_traceback(estate): assert rows[str(root)][0] == "keep" and "could not be listed" in rows[str(root)][3] +@pytest.mark.parametrize("breakage", ["head-removed", "gitfile-to-missing-gitdir", "objects-removed", "mode-000"]) +def test_a_lane_dir_whose_repository_git_cannot_read_still_refuses(estate, breakage): + """#170 G5 against a REAL failure, which the shim of + test_registrations_that_cannot_be_read_refuse_the_dry_run never makes: the + lane's recorded checkout IS a repository and `git worktree list` fails in + it. `rev-parse` then says "not a git repository" too, so a guard that reads + that answer alone as an absence would let the gate pass over it.""" + import shutil as _sh + gitdir = estate.checkout / ".git" + if breakage == "head-removed": + (gitdir / "HEAD").unlink() + elif breakage == "gitfile-to-missing-gitdir": + _sh.rmtree(gitdir) + gitdir.write_text(f"gitdir: {estate.root / 'gone' / '.git'}\n") + elif breakage == "objects-removed": + _sh.rmtree(gitdir / "objects") + else: + if os.geteuid() == 0: + pytest.skip("root reads a mode-000 directory") + gitdir.chmod(0) + try: + proc = estate.sweep(LANE, "--dry-run", "--porcelain") + finally: + if breakage == "mode-000": + gitdir.chmod(0o755) + assert proc.returncode == 2, proc.stdout + proc.stderr + assert f"the worktree registrations of {estate.checkout} could not be read" in proc.stdout + + +def test_a_lane_dir_that_holds_no_repository_is_an_absence(estate): + """The repoQ-1 shape (#163): a recorded `dir` with no `.git` at all has no + registrations to read, so it is never G5's refusal.""" + import shutil as _sh + _sh.rmtree(estate.checkout / ".git") + proc = estate.sweep(LANE, "--dry-run", "--porcelain") + assert "could not be read (git worktree list failed)" not in proc.stdout, proc.stdout + proc.stderr + assert proc.returncode in (0, 3), proc.stdout + proc.stderr + + def test_usage_errors_are_64(estate): for argv in (["--bogus"], [], ["--word", "x"]): proc = estate.sweep(*argv) if argv != [] else estate.sweep()