diff --git a/lanes b/lanes index fb2af52..b736714 100755 --- a/lanes +++ b/lanes @@ -406,7 +406,7 @@ EOF say "" say "$n worktree(s) in $nl lane(s) on $nw workstation(s), each as its lane's inventory last recorded it (LAST SEEN); nothing here is recomputed." if [ "$wt_unread" != 0 ]; then - say "$wt_unread lane(s) above have an inventory that is there and could not be read: that is NOT a lane with no worktrees." + say "$wt_unread lane(s) above have an inventory that could not be read: that is NOT a lane with no worktrees." fi if [ "$wt_partial" != 0 ]; then say "the lane listing above is NOT WHOLE: a lane it could not name is missing from it, and that is NOT a lane with no worktrees." diff --git a/lanes-edit.sh b/lanes-edit.sh index bae33fb..552f0b4 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -542,7 +542,11 @@ # item 4e). And `lane-state` spends it for a snapshot of this schema that # opens and is not a WHOLE record — cut short, or a key missing or out of # shape (#115): nothing can be read out of it as a snapshot, so it is -# the same answer, and the message says which of the two it is. +# the same answer, and the message says which of the two it is. Both +# spend it, too, where the record could not even be LOOKED FOR (#291): a +# control root, or a directory above it, that this process cannot search +# — or a link on the way it cannot follow — leaves stat as silent as a +# name that is gone, and the message says so rather than "is there". # # --no-sweep (DEFAULT, added 2026-09-09 after 0d84d34/a1f2438 swept another # lane's uncommitted hand edit into an unrelated commit): every mutating @@ -13579,8 +13583,66 @@ lane_state_word_ok() { # return 1 } +# A NAME THAT IS NOT THERE IS ABSENT ONLY WHERE THE LOOKUP THAT SAYS SO COULD +# BE MADE (opensoft/openRepoTools#291, the review of #289's SHOULD-4). `[ -d ]`, +# `[ -e ]`, `[ -r ]` and `[ -L ]` are all false for a path under a directory +# this process may not SEARCH: stat fails there with EACCES exactly as it fails +# with ENOENT for a name that is gone, and none of the shell's tests tells the +# two apart. So a lane whose `.lane-state` was mode 000 read as a lane with no +# trees and no snapshot — `lane-trees` and `lane-state` answered 8, the listing +# left the lane out, and `lanes-index sync` removed its rows — and a recorded +# `dir` whose parent could not be looked for fell to the next rung's root. The +# read that failed was taken for the answer (R22, Amendment 7(d)). +# +# So the walk goes up from the path to the deepest name that IS there. A +# directory this process can search has answered for the name below it, and a +# name that is not a directory has no name below it: the absence is +# established, 1. A directory that cannot be searched has not answered (0), and +# nor has a link that cannot be followed (0): its target is gone or is under a +# directory that cannot be searched, and stat cannot tell those two apart +# either. `-x` and not `-r`, because a lookup needs search permission alone. +# It is `lane_restart_root_ok`'s rule (unknown ancestry is never absent), asked +# of the one ancestor a lookup stops at. Where the answer is 0, +# `LOOKUP_BLOCKED_WHY` says where, in the words every reader below prints; call +# this directly, never inside `$( )`, or that is lost. +# +# ITS LIMIT (#291's review, NIT-4): the rule reads MODE BITS. A denial they do +# not show — macOS TCC's EPERM under a protected folder such as `~/Documents`, +# a Linux security module, a FUSE mount without `allow_other` — leaves the +# deepest name that is there looking searchable, so the name below it still +# reads as absent (1): #291's failure by another route. Telling the two apart +# needs the errno of the stat that failed, and none of the shell's tests +# reports one. +LOOKUP_BLOCKED_WHY="" +lookup_blocked() { # + lkb_x="${1-}"; LOOKUP_BLOCKED_WHY="" + [ -n "$lkb_x" ] || return 1 + while :; do + if [ -e "$lkb_x" ]; then + if [ -d "$lkb_x" ] && [ ! -x "$lkb_x" ]; then + LOOKUP_BLOCKED_WHY="the directory $lkb_x cannot be searched" + return 0 + fi + return 1 + fi + if [ -L "$lkb_x" ]; then + LOOKUP_BLOCKED_WHY="$lkb_x is a symbolic link that cannot be followed" + return 0 + fi + case "$lkb_x" in + /|.) return 1 ;; + */*) lkb_x="${lkb_x%/*}"; [ -n "$lkb_x" ] || lkb_x=/ ;; + *) lkb_x=. ;; + esac + done +} + # THE CONTROL ROOT — the three rungs above, in order. 0 with the path (which -# need not exist yet), 8 where no rung answers. +# need not exist yet), 8 where no rung answers. A rung is passed over only where +# its directory is established absent (#291): where whether it is there could +# not be looked for, ITS root is the answer — every read under it then meets the +# same directory nobody can search and says so, and every write fails there — +# and never the next rung's, a different root where nothing of this lane's is. lane_control_root() { # [] lcr_lane="${1-}"; lcr_pay="${2-}"; lcr_dir=""; lcr_par="" [ -n "$lcr_lane" ] || return 8 @@ -13596,13 +13658,17 @@ lane_control_root() { # [] /*) lcr_par="${lcr_dir%/*}" ;; *) lcr_par="" ;; esac - if [ -n "$lcr_par" ] && [ -d "$lcr_par" ]; then - printf '%s/.lane-state/%s\n' "$lcr_par" "$lcr_lane" - return 0 + if [ -n "$lcr_par" ]; then + if [ -d "$lcr_par" ] || lookup_blocked "$lcr_par"; then + printf '%s/.lane-state/%s\n' "$lcr_par" "$lcr_lane" + return 0 + fi fi - if [ -n "${PROJECTS_ROOT:-}" ] && [ -d "$PROJECTS_ROOT" ]; then - printf '%s/.lane-state/%s\n' "${PROJECTS_ROOT%/}" "$lcr_lane" - return 0 + if [ -n "${PROJECTS_ROOT:-}" ]; then + if [ -d "$PROJECTS_ROOT" ] || lookup_blocked "$PROJECTS_ROOT"; then + printf '%s/.lane-state/%s\n' "${PROJECTS_ROOT%/}" "$lcr_lane" + return 0 + fi fi return 8 } @@ -13780,7 +13846,8 @@ lane_op_id() { # here parses with one `awk`. 0 with the fields, 8 where the lane has no # snapshot at all (the pre-cutover lane, and not a failure), 10 where a snapshot # IS there and could not be read — or is of this schema and not a whole record -# (#115) — 1 where the control root could not be derived. +# (#115), or could not even be looked for (#291, `lookup_blocked`) — 1 where +# the control root could not be derived. # # THE 10 IS THE POINT OF THIS ROUND (Copilot round 6 on openRepoTools#97, where # it was 9 — main's #61 has since spent 9 on `claim`, so this one moved). A bare @@ -13808,6 +13875,9 @@ lane_state_read() { # [] lsr_f="$lsr_root/lane-state.yaml" if [ ! -r "$lsr_f" ]; then if [ -e "$lsr_f" ] || [ -L "$lsr_f" ]; then return 10; fi + # AND ONE NOBODY COULD LOOK FOR IS NOT AN ABSENT ONE (#291): its control + # root, or a directory above it, cannot be searched. + if lookup_blocked "$lsr_f"; then return 10; fi return 8 fi # AN UNKNOWN SCHEMA FAILS CLOSED (design decision: "conservative fail-closed @@ -14087,7 +14157,9 @@ lane_tree_put() { # # EVERY RECORDED TREE, ONE PER LINE, US-separated: # # 0 with rows, 8 with none, 1 where no control root could be derived, 10 where -# the `trees` directory is there and cannot be listed. +# the `trees` directory is there and cannot be listed — or could not even be +# looked for, its control root or a directory above it being one this process +# cannot search (#291). # # THAT 10 IS NOT AN 8 (#157 item 4e). A directory this process may not read or # search yields an empty glob, which read as a lane that recorded no tree — the @@ -14119,7 +14191,10 @@ lane_trees_list() { # [] ltl_root=""; ltl_rc=0; ltl_n=0 ltl_root="$(lane_control_root "${1-}" "${2-}")" || ltl_rc=$? [ "$ltl_rc" = 0 ] || return 1 - [ -d "$ltl_root/trees" ] || return 8 + if [ ! -d "$ltl_root/trees" ]; then + if lookup_blocked "$ltl_root/trees"; then return 10; fi + return 8 + fi [ -r "$ltl_root/trees" ] && [ -x "$ltl_root/trees" ] || return 10 for ltl_f in "$ltl_root"/trees/*.yaml; do [ -e "$ltl_f" ] || [ -L "$ltl_f" ] || continue @@ -14356,7 +14431,7 @@ lane_reconcile() { # fi # 1. THE SNAPSHOT. - lrc_state=""; lrc_gen=""; lrc_op=""; lrc_owner=""; lrc_upd="" + lrc_state=""; lrc_gen=""; lrc_op=""; lrc_owner=""; lrc_upd=""; lrc_sblk="" lrc_snap=""; lrc_srrc=0 lrc_snap="$(lane_state_read "$lrc_lane")" || lrc_srrc=$? if [ "$lrc_srrc" = 0 ]; then @@ -14385,6 +14460,11 @@ lane_reconcile() { # && ! lane_sidecar_schema_ok "$lrc_root/lane-state.yaml"; then lrc_state=MALFORMED; lrc_swhy="$LSS_WHY" fi + # AND ONE NOBODY COULD LOOK FOR (#291) is never said to be there. + if [ "$lrc_srrc" = 10 ] && [ ! -e "$lrc_root/lane-state.yaml" ] \ + && [ ! -L "$lrc_root/lane-state.yaml" ] && lookup_blocked "$lrc_root/lane-state.yaml"; then + lrc_sblk="$LOOKUP_BLOCKED_WHY" + fi fi printf 'ROOT%s%s\n' "$US" "$lrc_root" printf 'STATE%s%s%sgeneration %s%soperation %s%sowner %s%supdated %s\n' \ @@ -14474,15 +14554,24 @@ LRC_BIND lrc_seen=""; lrc_n=0; lrc_recover=0; lrc_dirty=0 # AN INVENTORY NOBODY COULD LIST IS NOT AN EMPTY ONE (#113, the unreadable # sidecar's rule one level up). `lane_trees_list` answers 10 for a `trees` - # directory that is there and cannot be listed (#157 item 4e), and this read - # ended in `|| :`, so every tree the lane recorded vanished from the report and - # its verdict could still be `resumable`. `lrc_unread` gathers what the - # inventory and the sweeps below could not read, and the verdict says so. + # directory that is there and cannot be listed (#157 item 4e), and for one + # that could not even be looked for (#291), and this read ended in `|| :`, so + # every tree the lane recorded vanished from the report and its verdict could + # still be `resumable`. `lrc_unread` gathers what the inventory and the sweeps + # below could not read, and the verdict says so. Which of the two 10s it was + # is asked again here, as `lane-trees` asks it, because the reason + # `lookup_blocked` gives is lost in the `$( )` the list is read in: a `trees` + # nobody could look for is never said to be there. lrc_unread=""; lrc_tl=""; lrc_tlrc=0 lrc_tl="$(lane_trees_list "$lrc_lane" 2>/dev/null)" || lrc_tlrc=$? case "$lrc_tlrc" in 0|8) : ;; - 10) lrc_unread="its worktree inventory directory $lrc_root/trees is there and could not be listed" ;; + 10) + if [ ! -d "$lrc_root/trees" ] && lookup_blocked "$lrc_root/trees"; then + lrc_unread="whether its worktree inventory directory $lrc_root/trees is there could not be read: $LOOKUP_BLOCKED_WHY" + else + lrc_unread="its worktree inventory directory $lrc_root/trees is there and could not be listed" + fi ;; *) lrc_unread="its worktree inventory could not be read (exit $lrc_tlrc)" ;; esac while IFS="$US" read -r lrc_id lrc_p lrc_b lrc_h lrc_u lrc_d lrc_np lrc_w lrc_ob lrc_co lrc_tg lrc_to lrc_sch; do @@ -14820,7 +14909,8 @@ LRC_PAIRED SWAPPED0) lrc_v=inconsistent; lrc_why="the lane is recorded SWAPPED and a holder is LIVE: a swapped lane has no holder, so one of the two is wrong and neither is overwritten here" ;; CLOSED8) lrc_v=closed; lrc_why="the lane is closed" ;; CLOSED0) lrc_v=inconsistent; lrc_why="the lane is recorded CLOSED and a holder is LIVE" ;; - UNREADABLE*) lrc_v=indeterminate; lrc_why="this lane's lifecycle snapshot at $lrc_root/lane-state.yaml IS THERE and could not be read, so where its session stopped is NOT established — which is not the same as a lane that has none, and is no clearance to relaunch anything (R22, Amendment 7(d): a read that failed is never an answer). Read that file by hand; if you know what wrote it, move it aside and this lane's next transition writes a fresh one" ;; + UNREADABLE*) lrc_v=indeterminate; lrc_why="this lane's lifecycle snapshot at $lrc_root/lane-state.yaml IS THERE and could not be read, so where its session stopped is NOT established — which is not the same as a lane that has none, and is no clearance to relaunch anything (R22, Amendment 7(d): a read that failed is never an answer). Read that file by hand; if you know what wrote it, move it aside and this lane's next transition writes a fresh one" + [ -z "$lrc_sblk" ] || lrc_why="whether this lane has a lifecycle snapshot at $lrc_root/lane-state.yaml could not be read: $lrc_sblk, so where its session stopped is NOT established — which is not the same as a lane that has none, and is no clearance to relaunch anything (R22, Amendment 7(d): a read that failed is never an answer). Its worktree inventory is under the same control root and could not be read either (#291). Read it by hand" ;; MALFORMED*) lrc_v=indeterminate; lrc_why="this lane's lifecycle snapshot at $lrc_root/lane-state.yaml is of this schema and ${lrc_swhy:-is not a whole record} — truncated, or edited by hand — so where its session stopped is NOT established, and no writer replaces it, exactly as none replaces a schema it does not write (#115). Read that file by hand; if you know what wrote it, move it aside and this lane's next transition writes a fresh one" ;; NONE*) lrc_v=no-state; lrc_why="this lane has no lifecycle snapshot: it has not started or handed off under this capability, so its crash kind cannot be told from its record (Amendment 7(i)'s cutover rule — nothing is backfilled)" ;; *) lrc_v=unknown-state; lrc_why="the snapshot holds the state word '$lrc_state', which this reader does not know; nothing is assumed about it" ;; @@ -14922,6 +15012,12 @@ LRC_PAIRED # not a tree at all; only a whole record's path is known absolute. A count that is # `unknown` is empty — an INTEGER column has no word for it. # +# A LANE THE LISTING NAMED AND COULD NOT READ IS ITS OWN `UNREAD` LINE, whose +# rows the indexer keeps: one whose `trees` directory is there and cannot be +# listed, one whose `trees` could not even be looked for - its control root, or +# a directory above it, cannot be searched - and one with a record that could +# not be read, beside that record's own row (#291). +# # AND THE LISTING ITSELF IS SAID TO BE WHOLE OR NOT (opensoft/openRepoTools#173 # item c1, Copilot's fourth review of #171). Its lanes are the names three # reads give — the register's rows, the archive's rows and the object logs' @@ -15074,7 +15170,20 @@ worktree_rows() { # [] wr_root=""; wr_rrc=0 wr_root="$(lane_control_root "$wr_l" "$wr_pay" 2>/dev/null)" || wr_rrc=$? [ "$wr_rrc" = 0 ] || continue - [ -d "$wr_root/trees" ] || continue + # AND ONE NOBODY COULD LOOK FOR IS NOT AN ABSENT ONE (#291, the review of + # #289's SHOULD-4). Where the control root, or a directory above it, cannot + # be searched, `-d` is false over trees that may well be there, and this + # `continue` made a lane with no trees of it: the sync removed its rows. + # The listing NAMED the lane, so it is the lane's own `UNREAD`, whose rows + # the indexer holds, and not the listing's `PARTIAL`, which is for a lane a + # read could not name. + if [ ! -d "$wr_root/trees" ]; then + if lookup_blocked "$wr_root/trees"; then + wr_out="${wr_out}UNREAD$US$WS$US$wr_l${US}whether its trees directory $wr_root/trees is there could not be read: $LOOKUP_BLOCKED_WHY +" + fi + continue + fi # A DIRECTORY THAT IS THERE AND CANNOT BE LISTED IS NOT AN EMPTY ONE, and # the trees it holds are not trees this lane no longer has (R22). if [ ! -r "$wr_root/trees" ] || [ ! -x "$wr_root/trees" ]; then @@ -15128,12 +15237,14 @@ worktree_rows() { # [] fi fi [ "$wr_owner" = legacy ] || wr_life=INDETERMINATE + wr_unrec="" while IFS="$US" read -r t_id t_p t_b t_h t_u t_d t_n t_w t_ob t_co t_g t_o t_s; do [ -n "${t_id:-}" ] || continue t_base=""; t_last="" if [ "${t_s:-}" = "" ]; then t_branch="unreadable sidecar"; [ -n "${t_p:-}" ] || t_p="$t_id" t_d=""; t_n=""; t_g=""; t_ob="" + wr_unrec="${wr_unrec:+$wr_unrec }$t_id" elif [ "${t_s:-}" = "" ]; then t_branch="malformed sidecar"; [ -n "${t_p:-}" ] || t_p="$t_id" t_d=""; t_n=""; t_g=""; t_ob="" @@ -15171,6 +15282,14 @@ worktree_rows() { # [] done </dev/null || printf '')/lane-state.yaml" + if [ ! -e "$lst_f" ] && [ ! -L "$lst_f" ] && lookup_blocked "$lst_f"; then + die "whether lane $lane has a lifecycle snapshot could not be read: $lst_f could not be looked for, because $LOOKUP_BLOCKED_WHY. That is NOT 'this lane has no snapshot' — 8 says that, and a launcher answers an 8 by going on, which here would be a launch made in ignorance of a crash nobody could look at (R22, Amendment 7(d)). Read it by hand." 10 + fi lst_what="it could not be read" if [ -r "$lst_f" ] && ! lane_sidecar_schema_ok "$lst_f"; then lst_what="it is of this schema and $LSS_WHY, so nothing can be read out of it as a snapshot"; fi die "lane $lane HAS a lifecycle snapshot at its control root and $lst_what. That is NOT 'this lane has no snapshot' — 8 says that, and a launcher answers an 8 by going on, which over an unreadable record would be a launch made in ignorance of a crash nobody could look at (R22, Amendment 7(d)). Read it by hand: $lst_f" 10 ;; @@ -18810,7 +18937,14 @@ EOF case "$ltr_rc" in 0) : ;; 8) exit 8 ;; - 10) die "lane $lane's worktree inventory directory $(lane_control_root "$lane" 2>/dev/null || printf '')/trees is there and could not be listed. That is NOT 'this lane recorded no tree' — 8 says that, and a reader that goes on over it relaunches writers onto trees nobody looked at (R22, Amendment 7(d)). Read it by hand." 10 ;; + 10) + # WHICH OF THE TWO 10s (#291): a `trees` directory nobody could look + # for is never said to be there. + ltr_root="$(lane_control_root "$lane" 2>/dev/null || printf '')" + if [ ! -d "$ltr_root/trees" ] && lookup_blocked "$ltr_root/trees"; then + die "whether lane $lane has a worktree inventory could not be read: $ltr_root/trees could not be looked for, because $LOOKUP_BLOCKED_WHY. That is NOT 'this lane recorded no tree' — 8 says that, and a reader that goes on over it relaunches writers onto trees nobody looked at (R22, Amendment 7(d)). Read it by hand." 10 + fi + die "lane $lane's worktree inventory directory $ltr_root/trees is there and could not be listed. That is NOT 'this lane recorded no tree' — 8 says that, and a reader that goes on over it relaunches writers onto trees nobody looked at (R22, Amendment 7(d)). Read it by hand." 10 ;; *) die "lane $lane has no lifecycle control root, so its worktree inventory could not be read. That is NOT 'this lane owns no worktree' (Amendment 7(d))." 1 ;; esac printf '%s\n' "$ltr_out" diff --git a/lanes-index b/lanes-index index a067452..8ea52b6 100755 --- a/lanes-index +++ b/lanes-index @@ -45,7 +45,7 @@ longer names leaves in the same sync. Its provenance moves only forward PER LANE: a sync that reads a lane's lifecycle generation below the one its rows were written at writes nothing for that lane (`reconcile` takes the lane as it now stands, and says so); a lane whose -inventory is there and could not be read keeps its rows and gets one marker +inventory could not be read keeps its rows and gets one marker row, with no path and a branch no git ref can be (#173), which `export --worktrees` writes back as the source's own `UNREAD` line; a read whose LANE LISTING was not whole - the helper's `PARTIAL` line: its register, its archive, @@ -1849,7 +1849,7 @@ def tree_line(row: dict, provenance) -> str: #: THE BRANCH OF A LANE'S UNREAD MARKER (opensoft/openRepoTools#173): a #: `worktrees` row with no path and a branch `unreadable inventory ()`, -#: saying the lane's inventory was there and could not be read at the last +#: saying the lane's inventory could not be read at the last #: sync, and why. A MARKER IS ITS KEY, `unread_key()` - `:` and a #: US - and nothing else decides it (the review of #219 and its delta check): #: no tree's key can carry a US after the colon but its own ``, while a @@ -1896,7 +1896,7 @@ def inventory_digest(record: dict) -> str: def lane_generation(lane: str) -> tuple: """`(generation, None)` of a lane's lifecycle snapshot, asked of `lanes-edit.sh lane-state` - 0 where the lane has no snapshot or no control root here - or - `(None, why)` where the snapshot is there and could not be read.""" + `(None, why)` where the snapshot could not be read.""" proc = _helper_run(["lane-state", lane], 120, f"lane {lane}'s lifecycle snapshot") if proc.returncode in (1, 8): return 0, None @@ -1912,7 +1912,7 @@ def lane_generation(lane: str) -> tuple: def collect_inventory() -> tuple[dict, dict]: """Every tree the inventory names, keyed `:` (a second tree of one lane with the same path `:`), and every lane whose - inventory is there and could not be read - whose rows are KEPT, never taken + inventory could not be read - whose rows are KEPT, never taken for a lane with no trees (Amendment 7(d)). A READ WHOSE LANE LISTING WAS NOT WHOLE IS NO ANSWER AT ALL @@ -2064,7 +2064,7 @@ def run_inventory(mode: str, requested: str | None, dry_run: bool) -> int: # know - #173 item c1.) held: dict = {} for lane, why in unread.items(): - held[lane] = f"its inventory is there and could not be read ({why})" + held[lane] = f"its inventory could not be read ({why})" read_gen: dict = {} for rec in trees.values(): read_gen[rec["lane"]] = max(read_gen.get(rec["lane"], 0), rec["generation"] or 0) diff --git a/tests/test_unsearchable_control_root_291.py b/tests/test_unsearchable_control_root_291.py new file mode 100644 index 0000000..fd20598 --- /dev/null +++ b/tests/test_unsearchable_control_root_291.py @@ -0,0 +1,442 @@ +# SPDX-License-Identifier: Apache-2.0 +"""opensoft/openRepoTools#291 (the review of #289, its SHOULD-4): A LANE CONTROL +ROOT THAT CANNOT BE SEARCHED IS NOT A LANE WITH NO TREES. + +A lane's trees and its lifecycle snapshot are under its control root, +`/.lane-state/` (or `$PROJECTS_ROOT/.lane-state/` +for a lane whose `dir` has no parent here). The shell's `[ -d ]`, `[ -e ]` and +`[ -r ]` are all false for a path under a directory this account may not +SEARCH - stat fails with EACCES there exactly as it fails with ENOENT for a +name that is gone - so a `.lane-state` at mode 000 read as a lane with no +trees (the review's probe P5): `lanes-edit.sh worktrees --all` said nothing of +the lane (or "none", exit 8, where it was the only lane with trees), and +`lanes-index sync` wrote `0 upserted, 1 removed`, deleting its rows. +`lane-trees ` and `lane-state ` answered 8 - "this lane recorded no +tree", "this lane has no snapshot" - which a launcher answers by going on, and +`lane-reconcile ` pronounced `no-state`. The rungs that place the root +did the same one level up: a recorded `dir` whose parent could not be looked +for fell to `$PROJECTS_ROOT`, a different root, and a `$PROJECTS_ROOT` that +could not be looked for left the lane no root at all. + +What these tests hold the helper to, for each place the lookup can stop - the +lane's `.lane-state`, its control root itself (readable and not searchable), a +directory above the recorded `dir`'s parent, one above `$PROJECTS_ROOT`, and a +`.lane-state` that is a link into a directory that cannot be searched: + + * the listing names the lane `UNREAD` - its inventory could not be read - and + never leaves it out. It is the lane's own line because the listing NAMED + the lane and only its inventory is unread, which is what `UNREAD` has + meant since #171; #289's `PARTIAL` is for a lane listing that could not + name a lane at all, and it holds every lane's rows, not this one's; + * `lanes-index sync` holds that lane's rows and removes nothing; + * `lane-trees ` and `lane-state ` exit 10, "could not be read", + and `lane-reconcile ` pronounces nothing (`indeterminate`); + * a root, a `trees` or a snapshot that is simply not there is still the 8 of + "none", and a lane with no trees still holds nothing in a sync. + +Two of #291's changes that none of those reads shows have cases of their own: a +write under a root nobody could look for fails there and is filed nowhere else +(it used to fall to `$PROJECTS_ROOT`'s root, where nothing of the lane's is and +the record was stranded once the block lifted), and a `.lane-state` link whose +target is gone is the same 10, since stat cannot tell it from a link into a +directory that cannot be searched. And a `trees` that is a link nobody can +follow, with the snapshot beside it read, has one too: the verdict is then +`indeterminate` for the inventory alone, and `lane-reconcile`'s reason says, +as `lane-trees` does, that whether the inventory directory is there could not +be read - never that it is there. + +Beside them: a `trees` directory that is there and cannot be searched (the +read #157 item 4e already made `UNREAD`, kept as it is), and a tree record that +cannot be read - `lane-trees` names it as its own `` row with exit +0, which the handoff reads per record (#255), and the listing now holds the +lane too, so a sync no longer swaps the tree's row for one with no path. + +The lane has trees and NO lifecycle snapshot, as in the review's probe. A +snapshot would hide the removal from a `sync`: it asks `lane-state` for the +generation of a lane whose rows would all leave, and rows written at +generation 1 that now read 0 are held, for the wrong reason, while `reconcile` +removes them. + +Every mode set here is restored before the case ends. Root searches and reads +a mode-000 directory, so a case that sets a mode proves nothing as root and +skips there; the two links that cannot be followed set none, and run as root +too. +""" + +from __future__ import annotations + +import contextlib +import os +import shutil +import stat +from pathlib import Path + +import pytest + +from conftest import WINDOWS_SKIP +from test_lanes_index import HEADS, US, UUID_B1, Inventory, _sidecar, _wt + +pytestmark = [ + WINDOWS_SKIP, + pytest.mark.skipif(shutil.which("bash") is None or shutil.which("git") is None, + reason="the lane tooling is bash over git"), +] + +#: ONLY A CASE THAT SETS A MODE SKIPS AS ROOT (the review of 124b509, NIT-3). +#: Root searches and reads a mode-000 directory, so a lookup a mode blocks is +#: not blocked there and the case proves nothing. A link that cannot be +#: followed sets no mode, and its cases prove the same thing as root. +NOT_AS_ROOT = pytest.mark.skipif( + getattr(os, "geteuid", lambda: -1)() == 0, + reason="root searches and reads a mode-000 directory: nothing to prove here") + +LANE = "repoB-1" +SIDECAR_ID = f"c0-{LANE}" +KEY = f"{LANE}:/nowhere/{LANE}/w" +HELD = f"lane {LANE}: its rows are kept as they are - " + + +@pytest.fixture +def inv(tmp_path): + """test_lanes_index's estate with an inventory, made here as it is there.""" + made = Inventory(tmp_path / "i") + yield made + made.close() + + +@contextlib.contextmanager +def _modes(changes: list[tuple[Path, int]]): + """Each path at its mode for the block, and every one put back to the mode + it had, in reverse order, whatever the block did.""" + done = [] + try: + for path, mode in changes: + restore = stat.S_IMODE(path.stat().st_mode) + path.chmod(mode) + done.append((path, restore)) + yield + finally: + for path, restore in reversed(done): + path.chmod(restore) + + +def _a_lane_with_trees(inv: Inventory, away: Path, env: dict, *, + state: Path | None = None, make_dir: bool = True) -> Path: + """repoB-1 RESUMED with `dir /repoB` - `away` need not be there - and + one tree, and no snapshot, under the control root that `dir` places + (`state`, `/.lane-state` by default; a lane whose `dir` has no parent + here is placed under `$PROJECTS_ROOT/.lane-state`), synced with no override + naming a control root. Returns that root. Before anything is recorded the + root is not there and the lookup that says so was made: the 8 of "none", + which is what this file must not take from a lane anywhere else.""" + if make_dir: + (away / "repoB").mkdir(parents=True, exist_ok=True) + log = inv.e.wip / "lanes" / "log" / f"{LANE}.md" + log.write_text(log.read_text(encoding="utf-8") + + f"RESUMED — lane {LANE}, session {UUID_B1}@Eagle, 2026-10-05T00:00:00Z, " + f"lane:{LANE} → dir {away / 'repoB'}; profile team-02a\n", encoding="utf-8") + inv.e._git("add", "-A") + inv.e._git("commit", "-q", "-m", f"{LANE} resumed with a dir of its own") + inv.e._git("push", "-q", "origin", "main") + root = (state or away / ".lane-state") / LANE + for verb in ("lane-trees", "lane-state"): + none = inv.e.edit(verb, LANE, env=env) + assert none.returncode == 8, (verb, none.returncode, none.stdout, none.stderr) + _sidecar(root.parent, LANE, HEADS[LANE]) + synced = inv.sync(env=env) + assert synced.returncode == 0, (synced.stdout, synced.stderr) + assert "kept as they are" not in synced.stdout, synced.stdout # no lane is unread yet + assert KEY in inv.rows(), sorted(inv.rows()) # found through `dir` alone + return root + + +def _layout(inv: Inventory, case: str) -> tuple[dict, Path, list[tuple[Path, int]], str]: + """`(env, control root, the modes that block it, the words the helper + gives for where the lookup stops)` for one case.""" + env = {"LANES_LANE_STATE_ROOT": None} + if case == "lane-state-dir": # the review's probe P5, as the issue gives it + away = inv.root / "elsewhere" + root = _a_lane_with_trees(inv, away, env) + where = away / ".lane-state" + return env, root, [(where, 0o000)], f"the directory {where} cannot be searched" + if case == "control-root": # readable, and not searchable: the x bit decides + away = inv.root / "elsewhere" + root = _a_lane_with_trees(inv, away, env) + return env, root, [(root, 0o600)], f"the directory {root} cannot be searched" + if case == "above-the-dirs-parent": # rung 2: the parent of `dir` could not be looked for + projects = inv.root / "projects" + projects.mkdir() + env["PROJECTS_ROOT"] = str(projects) + away = inv.root / "far" / "away" + root = _a_lane_with_trees(inv, away, env) + where = inv.root / "far" + return env, root, [(where, 0o000)], f"the directory {where} cannot be searched" + if case == "above-projects-root": # rung 3: `$PROJECTS_ROOT` could not be looked for + where = inv.root / "vault" + projects = where / "projects" + projects.mkdir(parents=True) + env["PROJECTS_ROOT"] = str(projects) + away = inv.root / "gone" # not there, so rung 2 has no parent here + root = _a_lane_with_trees(inv, away, env, state=projects / ".lane-state", + make_dir=False) + return env, root, [(where, 0o000)], f"the directory {where} cannot be searched" + if case == "link-into-a-locked-dir": # stat cannot tell this link from a dangling one + away = inv.root / "elsewhere" + locked = away / "locked" + (locked / "lane-state").mkdir(parents=True) + (away / ".lane-state").symlink_to(locked / "lane-state") + root = _a_lane_with_trees(inv, away, env) + where = away / ".lane-state" + return env, root, [(locked, 0o000)], f"{where} is a symbolic link that cannot be followed" + raise AssertionError(case) + + +def _lines(text: str, kind: str, lane: str | None = None) -> list[list[str]]: + rows = [line.split(US) for line in text.splitlines() if line.startswith(kind + US)] + return [r for r in rows if lane is None or r[2] == lane] + + +def _reconcile(text: str) -> dict: + return {r[0]: r[1:] for r in (line.split(US) for line in text.splitlines()) + if r[0] in ("ROOT", "STATE", "VERDICT")} + + +BLOCKED = ["lane-state-dir", "control-root", "above-the-dirs-parent", "above-projects-root", + "link-into-a-locked-dir"] + + +@NOT_AS_ROOT +@pytest.mark.parametrize("case", BLOCKED, ids=BLOCKED) +def test_a_control_root_nobody_could_search_is_not_a_lane_with_no_trees(inv, case): + """THE LOOKUP THAT SAYS A ROOT IS NOT THERE HAS TO HAVE BEEN MADE (#291). + Where it could not be - a directory on the way that cannot be searched, a + link on the way that cannot be followed - the lane's inventory and its + snapshot are UNREAD, never absent: the listing says so in the lane's own + `UNREAD` line, a sync holds the lane's rows and removes none, the named + reads exit 10, and the reconciliation pronounces nothing. Put back, the + next sync takes the lane's marker away and nothing else.""" + env, root, modes, why = _layout(inv, case) + unread = f"whether its trees directory {root}/trees is there could not be read: {why}" + kept = set(inv.rows()) + assert KEY in kept # not vacuous: the row a wrong read would remove + with _modes(modes): + assert not os.path.exists(root / "trees"), "the block did not take: nothing to prove" + res = inv.sync(env=env) + assert res.returncode == 0, (res.stdout, res.stderr) + assert " 0 removed" in res.stdout, res.stdout + assert KEY in inv.rows(), (res.stdout, sorted(inv.rows())) + assert HELD in res.stdout and f"({unread})" in res.stdout, res.stdout + + listed = inv.e.edit("worktrees", "--all", env=env) + assert listed.returncode == 0, (listed.returncode, listed.stdout, listed.stderr) + assert _lines(listed.stdout, "UNREAD", LANE) == [["UNREAD", "Eagle", LANE, unread]], \ + listed.stdout + assert _lines(listed.stdout, "TREE", LANE) == [], listed.stdout + assert _lines(listed.stdout, "PARTIAL") == [], listed.stdout # the lane IS named + + trees = inv.e.edit("lane-trees", LANE, env=env) + assert trees.returncode == 10, (trees.returncode, trees.stdout, trees.stderr) + assert trees.stdout == "", trees.stdout + assert f"whether lane {LANE} has a worktree inventory could not be read: {root}/trees " \ + f"could not be looked for, because {why}. That is NOT 'this lane recorded no " \ + f"tree'" in trees.stderr, trees.stderr + + state = inv.e.edit("lane-state", LANE, env=env) + assert state.returncode == 10, (state.returncode, state.stdout, state.stderr) + assert f"whether lane {LANE} has a lifecycle snapshot could not be read: " \ + f"{root}/lane-state.yaml could not be looked for, because {why}. That is NOT " \ + f"'this lane has no snapshot'" in state.stderr, state.stderr + + rec = inv.e.edit("lane-reconcile", LANE, env=env) + assert rec.returncode == 0, (rec.returncode, rec.stdout, rec.stderr) + facts = _reconcile(rec.stdout) + assert facts.get("ROOT") == [str(root)], rec.stdout + assert facts.get("STATE", [""])[0] == "UNREADABLE", rec.stdout + assert facts.get("VERDICT", [""])[0] == "indeterminate", rec.stdout + assert f"whether this lane has a lifecycle snapshot at {root}/lane-state.yaml could not " \ + f"be read: {why}, so where its session stopped is NOT established" \ + in facts["VERDICT"][1], rec.stdout + + named = _wt(inv, LANE, env=env) + assert named.returncode == 0, (named.returncode, named.stdout, named.stderr) + assert f"inventory NOT READ: {unread}" in named.stdout, named.stdout + healed = inv.sync(env=env) + assert healed.returncode == 0 and "0 upserted" in healed.stdout, healed.stdout + assert "kept as they are" not in healed.stdout, healed.stdout + assert set(inv.rows()) == kept, (healed.stdout, sorted(inv.rows())) + assert inv.e.edit("lane-trees", LANE, env=env).returncode == 0 + assert inv.e.edit("lane-state", LANE, env=env).returncode == 8 # none, and looked for + + +@NOT_AS_ROOT +def test_a_write_under_a_root_nobody_could_look_for_fails_and_lands_nowhere_else(inv): + """A WRITE GOES WHERE THE READS GO, OR NOWHERE (#291). The parent of the + recorded `dir` could not be looked for, so the lane keeps its own control + root and `set-lane-tree` meets the directory nobody can search there: it + exits nonzero and records nothing. It used to fall to the next rung and + file the tree under `$PROJECTS_ROOT/.lane-state`, where nothing of this + lane's is and where the record was stranded once the block lifted.""" + env, root, modes, _ = _layout(inv, "above-the-dirs-parent") + elsewhere = Path(env["PROJECTS_ROOT"]) / ".lane-state" + assert not elsewhere.exists() # nothing there yet: the write it must not make + with _modes(modes): + res = inv.e.edit("set-lane-tree", LANE, f"/nowhere/{LANE}/w2", "--branch", f"feat/{LANE}", + "--head", HEADS[LANE], "--upstream", "none", "--dirty", "0", + "--unpushed", "0", env=env) + assert res.returncode != 0, (res.returncode, res.stdout, res.stderr) + assert f"could not be written under {root}/trees" in res.stderr, res.stderr + assert not elsewhere.exists(), sorted(elsewhere.rglob("*")) + trees = inv.e.edit("lane-trees", LANE, env=env) + assert trees.returncode == 0, (trees.returncode, trees.stderr) + assert [line.split(US)[0] for line in trees.stdout.splitlines()] == [SIDECAR_ID], trees.stdout + + +def test_a_lane_state_link_whose_target_is_gone_is_unread_not_none(inv): + """A LINK ON THE WAY THAT CANNOT BE FOLLOWED IS NOT A LANE WITH NO TREES + (#291). Its target is gone, and stat cannot tell that from a link into a + directory nobody can search, so it is the same 10: `lane-trees` and + `lane-state` say the lane's inventory and its snapshot could not be read, + never the 8 a launcher answers by going on. With the target put back, the + lane has its tree again and, looked for, no snapshot.""" + env = {"LANES_LANE_STATE_ROOT": None} + away = inv.root / "elsewhere" + target, aside, link = away / "lane-state", away / "lane-state-aside", away / ".lane-state" + target.mkdir(parents=True) + link.symlink_to(target) + root = _a_lane_with_trees(inv, away, env) + why = f"{link} is a symbolic link that cannot be followed" + target.rename(aside) + try: + assert link.is_symlink() and not link.exists(), "the link does not dangle: nothing to prove" + trees = inv.e.edit("lane-trees", LANE, env=env) + assert trees.returncode == 10, (trees.returncode, trees.stdout, trees.stderr) + assert f"whether lane {LANE} has a worktree inventory could not be read: {root}/trees " \ + f"could not be looked for, because {why}. That is NOT 'this lane recorded no " \ + f"tree'" in trees.stderr, trees.stderr + state = inv.e.edit("lane-state", LANE, env=env) + assert state.returncode == 10, (state.returncode, state.stdout, state.stderr) + assert f"whether lane {LANE} has a lifecycle snapshot could not be read: " \ + f"{root}/lane-state.yaml could not be looked for, because {why}. That is NOT " \ + f"'this lane has no snapshot'" in state.stderr, state.stderr + finally: + aside.rename(target) + assert inv.e.edit("lane-trees", LANE, env=env).returncode == 0 + assert inv.e.edit("lane-state", LANE, env=env).returncode == 8 # none, and looked for + + +def test_a_trees_link_that_cannot_be_followed_is_never_said_to_be_there(inv): + """`lane-reconcile` SAYS IT AS `lane-trees` DOES (#291, the review's NIT-1). + `trees` is a link onto a volume, and the lane is SWAPPED with no holder + live: while the link can be followed, the tree is read through it and the + verdict is `resumable`. With the volume gone the snapshot beside it still + reads, so the verdict is `indeterminate` for the inventory alone - and its + reason says that whether the inventory directory is there could not be + read, and why, never that it is there and could not be listed: a link + that cannot be followed never saw whether it is. A `trees` directory that + is there and cannot be listed keeps those words + (test_lifecycle_fail_open_113_114_115).""" + env = {"LANES_LANE_STATE_ROOT": None} + root = _a_lane_with_trees(inv, inv.root / "elsewhere", env) + for move in (("SWAPPING", "--operation", "op-test-b1s"), + ("SWAPPED", "--expect", "SWAPPING", "--expect-operation", "op-test-b1s")): + put = inv.e.edit("set-lane-state", LANE, *move, env=env) + assert put.returncode == 0, (move, put.stdout, put.stderr) + volume, gone = inv.root / "usb", inv.root / "usb-gone" + trees, target = root / "trees", volume / "trees" + volume.mkdir() + trees.rename(target) + trees.symlink_to(target) + + def reconcile() -> tuple[dict, str]: + rec = inv.e.edit("lane-reconcile", LANE, env=env) + assert rec.returncode == 0, (rec.returncode, rec.stdout, rec.stderr) + return _reconcile(rec.stdout), rec.stdout + + facts, out = reconcile() + assert facts.get("STATE", [""])[0] == "SWAPPED", out + assert f"TREES{US}1 inventoried{US}" in out, out # read through the link + assert facts.get("VERDICT", [""])[0] == "resumable", out # the clearance at stake + unread = f"whether its worktree inventory directory {trees} is there could not be read: " \ + f"{trees} is a symbolic link that cannot be followed" + volume.rename(gone) + try: + assert trees.is_symlink() and not trees.exists(), "the link does not dangle: nothing to prove" + facts, out = reconcile() + assert facts.get("ROOT") == [str(root)], out + assert facts.get("STATE", [""])[0] == "SWAPPED", out # the snapshot reads + assert facts.get("VERDICT", [""])[0] == "indeterminate", out + assert f"({unread})" in facts["VERDICT"][1], out + assert "is there and could not be listed" not in out, out + finally: + gone.rename(volume) + facts, out = reconcile() + assert facts.get("VERDICT", [""])[0] == "resumable", out + + +@NOT_AS_ROOT +def test_a_trees_directory_that_cannot_be_searched_is_still_unread(inv): + """ONE LEVEL DOWN, AS IT WAS (#157 item 4e, Copilot round 2 on #171): a + `trees` directory that is there and readable and cannot be searched is a + lane whose inventory could not be read - `UNREAD` in the listing, its rows + held by a sync, and `lane-trees`' 10 in the words it has always had. The + lookup of the root was made, so nothing of #291's is said, and the snapshot + beside it, which is not there, is still the 8 of "none".""" + env = {"LANES_LANE_STATE_ROOT": None} + root = _a_lane_with_trees(inv, inv.root / "elsewhere", env) + with _modes([(root / "trees", 0o600)]): + listed = inv.e.edit("worktrees", "--all", env=env) + assert listed.returncode == 0, listed.stderr + assert _lines(listed.stdout, "UNREAD", LANE) == [[ + "UNREAD", "Eagle", LANE, + f"its trees directory {root}/trees is there and could not be listed"]], listed.stdout + res = inv.sync(env=env) + assert res.returncode == 0 and " 0 removed" in res.stdout, (res.stdout, res.stderr) + assert HELD in res.stdout, res.stdout + assert KEY in inv.rows() + trees = inv.e.edit("lane-trees", LANE, env=env) + assert trees.returncode == 10, (trees.returncode, trees.stderr) + assert f"lane {LANE}'s worktree inventory directory {root}/trees is there and could not " \ + f"be listed. That is NOT 'this lane recorded no tree'" in trees.stderr, trees.stderr + state = inv.e.edit("lane-state", LANE, env=env) + assert state.returncode == 8, (state.returncode, state.stdout, state.stderr) + healed = inv.sync(env=env) + assert healed.returncode == 0 and "0 upserted, 1 removed" in healed.stdout, healed.stdout + + +@NOT_AS_ROOT +def test_a_tree_record_that_cannot_be_read_holds_its_lanes_rows(inv): + """A RECORD THAT COULD NOT BE READ IS NOT A TREE WITH NO PATH. The listing + names it as it did - a row of its own, `unreadable sidecar`, its file's + id where the path would be - and `lane-trees` gives it as the + `` row the handoff reads per record (#255), with exit 0. But a + sync took that row for the whole truth: keyed by the id, it upserted it and + REMOVED the tree's row - its path, its branch, its counts - as a tree the + lane no longer had. The lane's inventory is there and could not be read, + so the listing now says so in the lane's `UNREAD` line too, and a sync + holds the lane's rows and removes nothing.""" + env = {"LANES_LANE_STATE_ROOT": None} + root = _a_lane_with_trees(inv, inv.root / "elsewhere", env) + record = root / "trees" / f"{SIDECAR_ID}.yaml" + assert record.is_file() + with _modes([(record, 0o000)]): + res = inv.sync(env=env) + assert res.returncode == 0, (res.stdout, res.stderr) + assert " 0 removed" in res.stdout, res.stdout + assert HELD in res.stdout, res.stdout + assert KEY in inv.rows() and f"{LANE}:{SIDECAR_ID}" not in inv.rows(), sorted(inv.rows()) + listed = inv.e.edit("worktrees", "--all", env=env) + assert listed.returncode == 0, listed.stderr + assert _lines(listed.stdout, "UNREAD", LANE) == [[ + "UNREAD", "Eagle", LANE, + f"these records under {root}/trees could not be read: {SIDECAR_ID}"]], listed.stdout + tree = _lines(listed.stdout, "TREE", LANE) + assert len(tree) == 1 and (tree[0][4], tree[0][5]) == (SIDECAR_ID, "unreadable sidecar"), \ + listed.stdout + trees = inv.e.edit("lane-trees", LANE, env=env) + assert trees.returncode == 0, (trees.returncode, trees.stderr) + rows = [line.split(US) for line in trees.stdout.splitlines()] + assert len(rows) == 1 and rows[0][0] == SIDECAR_ID and rows[0][1] == "" \ + and rows[0][12] == "", trees.stdout + healed = inv.sync(env=env) + assert healed.returncode == 0 and "0 upserted, 1 removed" in healed.stdout, healed.stdout + assert KEY in inv.rows()