diff --git a/lanes-edit.sh b/lanes-edit.sh index 40d65d1..ddc09c7 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -542,7 +542,10 @@ # 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. Both +# the same answer, and the message says which of the two it is. And it +# spends it where no `LANES_LANE_STATE_ROOT` names the control root and +# the lane's object log could not be read (#290): the log's `dir` places +# the snapshot, so where it is kept is not known, which is not none. 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 @@ -3384,6 +3387,15 @@ log_files() { done } +# ONE PARSER RUN PER FILE, AND A FILE THAT WILL NOT OPEN IS SAID +# (opensoft/openRepoTools#290). With no `origin` the logs are this checkout's +# files, and they were ONE `awk` run: a file it could not open left no line +# naming it - the listing took its lane for one with no log, and the +# inventory's read for whole - and `mawk` (Debian's and Ubuntu's `awk`) ends at +# the first file it cannot open, so every log after it was gone too. Each file +# is its own run now, as each published log is its own `git show`, and one +# that fails is said with the prefix `remote_log_events` says a published one +# with, which the listing and the inventory read back out of the warnings. log_events() { le_files=() if [ "$#" -gt 0 ]; then @@ -3394,7 +3406,13 @@ $(log_files) EOF fi [ "${#le_files[@]}" -gt 0 ] || return 0 - awk -v FN="" "$LOG_AWK" "${le_files[@]}" + le_rc=0 + for le_f in "${le_files[@]}"; do + awk -v FN="" "$LOG_AWK" "$le_f" && continue + le_rc=$? + note "unreadable log: $LANES_LOG_PREFIX${le_f##*/} — the file could not be read, so none of its lines are in this read" + done + return "$le_rc" } # Is there an `origin/` to read from at all? Outside a repository, and @@ -3522,7 +3540,21 @@ state_events_flush() { # ambiguous and refuses. One resolver now answers both, and its 2 is carried: # `lane_log_exists` spends it rather than 1, so "there are two logs" is never # read as "there is no log". -lane_log_events() { +# +# `checked` MAKES THE READ SAY WHETHER IT HAPPENED (opensoft/openRepoTools#290). +# Without it, the published branch's status is the parser's: a `git show` that +# failed is an empty stream and 0, exactly a lane with no log, and a named read +# took that for a lane whose log records no `dir` - its root fell to the third +# rung and its trees read as none. With it, a log `origin`'s tree names and git +# could not show is 1, and so is a tree that could not be listed to ask; a path +# the tree does not name is still no log and 0. With no `origin`, a log +# directory that is there and cannot be searched is 1, and so is a file that is +# there and will not open. The callers that read this status without asking +# for it are left as they are: making it the default changes what `lane-dir`, +# `lane-profile`, `lane-agent`, `lane-transcript`, `history`, `lane-last` and the +# binding reads answer over a log git cannot read, which #290 lists for a +# ruling rather than changing beside a named read. +lane_log_events() { # [checked] ll_rel="$(log_path_ci "$1")" || return 2 if [ -n "$LANES_IDX_DIR" ]; then # A LOG THE INDEX DOES NOT HOLD IS A LOG `origin` DID NOT HOLD at the @@ -3540,10 +3572,25 @@ lane_log_events() { return 0 elif have_remote_ref; then git -C "$LANES_REPO" show "origin/$LANES_BRANCH:$ll_rel" 2>/dev/null | parse_log_stream "$ll_rel" + ll_st=("${PIPESTATUS[@]}") + [ "${2-}" = checked ] || return "${ll_st[1]}" + [ "${ll_st[0]}" != 0 ] || return "${ll_st[1]}" + ll_in="" + ll_in="$(git -C "$LANES_REPO" ls-tree --name-only "origin/$LANES_BRANCH" -- "$ll_rel" 2>/dev/null)" || return 1 + [ -z "$ll_in" ] || return 1 + return 0 else ll_f="$LANES_LOG_DIR/${ll_rel##*/}" - [ -f "$ll_f" ] || return 0 - log_events "$ll_f" + if [ ! -f "$ll_f" ]; then + [ "${2-}" = checked ] || return 0 + [ ! -e "$ll_f" ] && [ ! -L "$ll_f" ] || return 1 + if [ -e "$LANES_LOG_DIR" ] || [ -L "$LANES_LOG_DIR" ]; then + [ -d "$LANES_LOG_DIR" ] && [ -x "$LANES_LOG_DIR" ] || return 1 + fi + return 0 + fi + [ "${2-}" = checked ] || { log_events "$ll_f"; return; } + log_events "$ll_f" || return 1 fi } lane_log_exists() { @@ -4203,6 +4250,16 @@ home_of_lane() { # `ls-tree` that could fail after it (the review of #289, SHOULD-1(b)). PIN_LOGS=0 PIN_LOGS_TEXT="" +# `PIN_LANE_LOG` IS A NAMED READ'S ONE READ OF ITS LANE'S LOG, and its status +# (opensoft/openRepoTools#290): `worktree_rows ` reads it once and the +# listing it asks for its row takes it from here, as the whole listing takes +# `PIN_LOGS`. +PIN_LANE_LOG=0 +PIN_LANE_LOG_TEXT="" +PIN_LANE_LOG_RC=0 +# 0 · 1 the published list of the logs could not be made, which lists nothing +# and is NOT a list with no logs in it (opensoft/openRepoTools#290): `lanes_rows` +# says that the lanes it would have named are missing. known_lanes() { if [ -n "$LANES_IDX_DIR" ]; then while IFS= read -r kl_f; do @@ -4214,16 +4271,20 @@ known_lanes() { return 0 fi if [ "$PIN_LOGS" = 1 ] || have_remote_ref; then - { if [ "$PIN_LOGS" = 1 ]; then printf '%s' "$PIN_LOGS_TEXT" - else git -C "$LANES_REPO" ls-tree --name-only "origin/$LANES_BRANCH" -- "$LANES_LOG_PREFIX" 2>/dev/null - fi; } \ + kl_list=""; kl_rc=0 + if [ "$PIN_LOGS" = 1 ]; then kl_list="$PIN_LOGS_TEXT" + else + kl_list="$(git -C "$LANES_REPO" ls-tree --name-only "origin/$LANES_BRANCH" -- "$LANES_LOG_PREFIX" 2>/dev/null && printf x)" || kl_rc=1 + kl_list="${kl_list%x}" + fi + printf '%s' "$kl_list" \ | while IFS= read -r kl_f; do [ -n "$kl_f" ] || continue kl_n="${kl_f##*/}" case "$kl_n" in README.md) continue ;; esac printf '%s\n' "${kl_n%.md}" done - return 0 + return "$kl_rc" fi while IFS= read -r kl_f; do [ -n "$kl_f" ] || continue @@ -7193,9 +7254,11 @@ lane_row_facts() { # events on stdin, ONE LINE PER LANE # under Amendment 11 yet"* — the pre-cutover answer that sends `restart`, # `lane-start` and both skills to the rungs beneath a record they never # established was readable. The read is its own step now and its status decides. -lane_payload_field() { # [all] +# `checked` IS `lane_log_events`' (#290): without it the published read's +# status is still the parser's, so a log git could not show is still the 8. +lane_payload_field() { # [all] [checked] lpf_lines=""; lpf_rc=0 - lpf_lines="$(lane_log_events "$1")" || lpf_rc=$? + lpf_lines="$(lane_log_events "$1" "${4-}")" || lpf_rc=$? [ "$lpf_rc" = 0 ] || return 1 # The scan is over EVERY lane-kind payload, newest last, and the answer is # the last one that actually carries the sub-field. @@ -8253,6 +8316,7 @@ lanes_rows() { # EACH NAME CARRIES ITS OWN LOWER-CASED KEY, emitted by the pass that already # computes it for the de-duplication: the three lookups below join on that key # and `lc` is two processes (ruling 12). + lr_klrc=0 if [ -n "$lr_one" ]; then lr_names="$(printf '%s\n' "$lr_one" | awk -v sep="$US" 'NF { print tolower($0) sep $0 }')" lr_here=0; lr_repo=""; lr_dir=""; lr_prefix="" @@ -8264,9 +8328,16 @@ lanes_rows() { # canonical one. Read the other way round, a lane whose log file predates # its row's spelling was listed under the file name, and column 10's # line that binds the lane offered that spelling back to the reader. - lr_names="$( { register_lanes 2>/dev/null || :; known_lanes 2>/dev/null || :; } | awk -v sep="$US" 'NF && !seen[tolower($0)]++ { print tolower($0) sep $0 }')" + # AND THE LIST OF THE LOGS SAYS WHETHER IT WAS MADE (opensoft/openRepoTools#290). + # It is what names a lane that has a log and no row, and a list that failed + # named none of them: they were missing from the listing, and nothing said + # so. It is said below. An empty listing is decided there too, after every + # read it rests on has been asked, because one over reads that failed is + # not "no lane". + lr_kl="" + lr_kl="$(known_lanes 2>/dev/null)" || lr_klrc=$? + lr_names="$( { register_lanes 2>/dev/null || :; printf '%s\n' "$lr_kl"; } | awk -v sep="$US" 'NF && !seen[tolower($0)]++ { print tolower($0) sep $0 }')" fi - [ -n "$lr_names" ] || return 8 # ONE PASS OVER EVERY LANE'S EVENTS, NOT ONE READ PER LANE PER FIELD. # `lanes` asks about every lane the register and the logs know, and this loop # used to make a `git show` of each lane's log — and then a fork per sub-field @@ -8280,8 +8351,21 @@ lanes_rows() { # lane's profile and nothing else, and `state_events` is a `git show` per log # file — nine seconds on the live register. The same parser over the same # grammar either way, so the two answers cannot differ. + # AND THAT ONE LOG IS READ ONCE, WITH ITS STATUS (opensoft/openRepoTools#290). + # Piped straight into the facts, a `git show` that failed was a lane whose + # log carries no lane-kind line: no `dir`, no binding, and a class of + # DORMANT. The read is its own step now. A named `worktrees` read has made it + # already and hands it here (`PIN_LANE_LOG`), so the row it lists and the + # root its trees are found under come out of one read and not two. + lr_evrc=0 if [ -n "$lr_one" ]; then - lr_facts="$(lane_log_events "$lr_one" 2>/dev/null | lane_row_facts)" + lr_ev="" + if [ "$PIN_LANE_LOG" = 1 ]; then + lr_ev="$PIN_LANE_LOG_TEXT"; lr_evrc="$PIN_LANE_LOG_RC" + else + lr_ev="$(lane_log_events "$lr_one" checked 2>/dev/null)" || lr_evrc=$? + fi + lr_facts="$(printf '%s\n' "$lr_ev" | lane_row_facts)" else lr_facts="$(state_events 2>/dev/null | lane_row_facts)" fi @@ -8291,19 +8375,34 @@ lanes_rows() { # and those lanes are fenced out of the class here. Without the warnings file # (no temporary file could be made) no read can be told from a failed one, so # no row is classed dormant at all. - lr_unread=" "; lr_unread_ok=1; lr_unread_paths="" + # AND THE EVENTS' OWN LIST OF THE LOGS IS ASKED THE SAME WAY (#290): where it + # could not be made, no lane's lines are in the facts, and every row with no + # log of its own here read as one with no object log - DORMANT, hidden, and + # offered to the sweep. + lr_unread=" "; lr_unread_ok=1; lr_unread_paths=""; lr_unlisted="" if [ -z "$lr_one" ] && [ -z "$LANES_IDX_DIR" ]; then if [ -n "$SE_WARN_FILE" ]; then - lr_unread_paths="$(sed -n -e 's/^lanes-edit: unreadable log: \(.*\) — git could not read it.*$/\1/p' "$SE_WARN_FILE" 2>/dev/null)" + lr_unread_paths="$(sed -n -e 's/^lanes-edit: unreadable log: \(.*\) — git could not read it.*$/\1/p' -e 's/^lanes-edit: unreadable log: \(.*\) — the file could not be read.*$/\1/p' "$SE_WARN_FILE" 2>/dev/null)" lr_unread=" $(printf '%s\n' "$lr_unread_paths" | awk 'NF { n = $0; sub(/^.*\//, "", n); sub(/\.md$/, "", n); print tolower(n) }' | tr '\n' ' ')" + lr_unlisted="$(sed -n -e '/^lanes-edit: unlisted logs: /p' "$SE_WARN_FILE" 2>/dev/null)" else lr_unread_ok=0 fi fi + # THE NAMED LANE'S ONE READ IS FENCED THE SAME WAY (#290): a log this listing + # could not read is not one that carries no lane-kind line. + if [ -n "$lr_one" ] && [ "$lr_evrc" != 0 ]; then + lr_unread_paths="$(log_path_ci "$lr_one" 2>/dev/null || log_path_for "$lr_one")" + lower_into "$lr_one"; lr_unread=" $LOWER_OUT " + fi [ -z "$lr_unread_paths" ] || note "these published logs could not be read, so no lane of theirs is classed DORMANT: $(printf '%s\n' "$lr_unread_paths" | tr '\n' ' ')" [ "$lr_unread_ok" = 1 ] || note "no temporary file could be made for the read's warnings, so whether any published log failed to read is not known, and no row is classed DORMANT here." + [ -z "$lr_unlisted" ] || + note "the object logs under origin/$LANES_BRANCH:$LANES_LOG_PREFIX could not be listed for their lines, so no lane's log is read in this listing, and no row is classed DORMANT here." + [ "$lr_klrc" = 0 ] || + note "the object logs under origin/$LANES_BRANCH:$LANES_LOG_PREFIX could not be listed, so a lane with a log and no row in the register or the archive is missing from this listing." # AND A REGISTER THAT COULD NOT BE READ IS SAID, ONCE (the re-review of #230). # Every row above came out of `register_text`, which drops the status # `register_text_checked` keeps (#67), and a published register git cannot @@ -8330,9 +8429,19 @@ lanes_rows() { # front of a launch), so it says it, exactly as it says a session-record read # that failed; the POSITION is the half that must not guess, and # `lane_next_free` refuses there rather than offering one over a partial read. + lr_arch_ok=1 if ! archive_text >/dev/null 2>&1; then + lr_arch_ok=0 note "$LANES_ARCH_PATH exists and could not be read, so the RETIRED rows it holds are not in this listing — and \`next-free\` will refuse a position rather than offer one over a partial read." fi + # AN EMPTY LISTING OVER READS THAT FAILED IS NOT "NO LANE" (#290). It was + # 8 before any of the notes above, so `lanes` printed "no lane is recorded" + # over a register, an archive and a list of the logs it could not read. It + # is 1, the listing could not be read, after each read that failed is said. + if [ -z "$lr_names" ]; then + [ "$lr_reg_ok" = 1 ] && [ "$lr_arch_ok" = 1 ] && [ "$lr_klrc" = 0 ] || return 1 + return 8 + fi lr_index="$GS$(lanes_register_index 2>/dev/null | tr '\n' "$GS" || :)" # THE CELLS ARE BUILT ON FIRST USE AND NOT BEFORE (see `lanes_register_index # --cells`): a listing whose every row has an object log never asks for them, @@ -8606,7 +8715,7 @@ EOF2 # row stays listed and the sweep is never offered it. if [ -n "$lr_row" ] && [ "$lr_lanekind" = 0 ] && [ "$lr_live_rc" = 0 ] && [ "$lr_cellrow" = 1 ] && [ "$lr_logs_rc" = 0 ] && [ "$lr_unread_ok" = 1 ] && - [ "$lr_reg_ok" = 1 ]; then + [ "$lr_reg_ok" = 1 ] && [ -z "$lr_unlisted" ]; then lr_class=dormant # A LOG THIS CHECKOUT HAS AND `origin` HAS NOT IS STILL AN OBJECT LOG, # AND SO IS ONE `origin` HAS AND GIT COULD NOT READ (item 11). @@ -15065,19 +15174,41 @@ LRC_PAIRED # says what that leaves. An empty listing over a failed read is that line # alone, and never 8. # +# AND A NAMED READ IS NEVER "NONE" OVER A READ THAT FAILED +# (opensoft/openRepoTools#290, the review of #289's SHOULD-3). Its lane's trees +# are found under the root its log's `dir` places, and the listing's one read of +# that log threw a failed `git show` away: no `dir`, the third rung, no `trees`, +# and 8 - "no worktree is inventoried". The log is read here ONCE, with its +# status, and handed to the listing (`PIN_LANE_LOG`); where it could not be read +# and no override names the root, the lane is ONE `UNREAD` line naming the log. +# A lane's name is the register's spelling, and the caller says whether the +# register read it was resolved by was made (`register-unread`): where it was +# not, a name typed in another case named a root no lane has, so a read that +# finds no tree there is an `UNREAD` line naming the register. +# +# AND NO WRITER IS PRONOUNCED `0` OVER A READ NOBODY MADE (#290). `0` is "no +# live record here names one of the lane's ids, and its binding is here or +# nowhere": where the register's ids were not read, or the lane's log - its +# binding - was not, that is not established, and the field is EMPTY, unknown, +# as it is for a lane bound elsewhere. A live record that names one of the ids +# that WERE read is still `1`. +# # The rows are sorted bytewise, which is the order the index's copy is written # in too: the two reads of one inventory are the same bytes. # 0 rows, with a PARTIAL line where the listing was not whole · 8 none · # 1 the lane listing could not be read -worktree_rows() { # [] +worktree_rows() { # [ [register-unread]] wr_one="${1-}"; wr_out="" wr_live_ok=1 live_session_ids >/dev/null 2>&1 || wr_live_ok=0 wr_partial="" wr_unlisted="the object logs under origin/$LANES_BRANCH:$LANES_LOG_PREFIX could not be listed" + wr_reg_ok=1; wr_evrc=0; wr_lp=""; wr_unread_lanes=" "; wr_binding_unread=0 if [ -z "$wr_one" ]; then - register_text_checked >/dev/null 2>&1 || + if ! register_text_checked >/dev/null 2>&1; then wr_partial="the register $LANES_PATH could not be read" + wr_reg_ok=0 + fi # THE ARCHIVE AND THE LOG LIST ARE PINNED, NOT ONLY ASKED (SHOULD-1(b)): # each read that succeeds here is the text every read of the listing below # is handed, as the register's is its cache. `x` keeps the text's last @@ -15088,8 +15219,11 @@ worktree_rows() { # [] else wr_partial="${wr_partial:+$wr_partial; }the archive $LANES_ARCH_PATH could not be read" fi - # `known_lanes`' OWN TWO READS, which print what they could and answer 0 - # either way, because the listing unions them with the register. WITH NO + # `known_lanes`' OWN TWO READS, which print what they could, because the + # listing unions them with the register. The published list answers 1 + # where it could not be made (#290) - the listing says so and lists the + # rest - and the directory's, with no `origin`, answers 0 either way: so + # whether each was made is asked here, for the `PARTIAL` line. WITH NO # `origin`, A DIRECTORY THAT IS THERE IS ASKED AS THE TREES' IS BELOW: `ls` # lists an `r--` one, whose files no glob can then open (N1 of the review). if have_remote_ref; then @@ -15104,6 +15238,12 @@ worktree_rows() { # [] wr_partial="${wr_partial:+$wr_partial; }the log directory $LANES_LOG_DIR is there and could not be listed" fi fi + else + [ "${2-}" != register-unread ] || wr_reg_ok=0 + wr_ev="" + wr_ev="$(lane_log_events "$wr_one" checked 2>/dev/null)" || wr_evrc=$? + PIN_LANE_LOG_TEXT="$wr_ev"; PIN_LANE_LOG_RC="$wr_evrc"; PIN_LANE_LOG=1 + [ "$wr_evrc" = 0 ] || wr_lp="$(log_path_ci "$wr_one" 2>/dev/null || log_path_for "$wr_one")" fi wr_lanes=""; wr_lrc=0 if [ -n "$wr_one" ]; then @@ -15112,21 +15252,29 @@ worktree_rows() { # [] wr_lanes="$(lanes_rows --all --closed)" || wr_lrc=$? fi PIN_ARCHIVE=0; PIN_ARCHIVE_TEXT=""; PIN_LOGS=0; PIN_LOGS_TEXT="" + PIN_LANE_LOG=0; PIN_LANE_LOG_TEXT=""; PIN_LANE_LOG_RC=0 + # 1 IS ALSO A LISTING THAT READS THAT FAILED LEFT EMPTY (#290), which the + # PARTIAL line below says where this read found those reads failed too. case "$wr_lrc" in 0) : ;; - 8) [ -n "$wr_partial" ] || return 8 ;; + 8|1) [ -n "$wr_partial" ] || return "$wr_lrc" ;; *) return 1 ;; esac if [ -z "$wr_one" ] && [ "$wr_lrc" = 0 ]; then wr_ulogs=""; wr_urc=0; wr_ulist="" if [ -n "$SE_WARN_FILE" ] && [ -f "$SE_WARN_FILE" ]; then - wr_ulogs="$(sed -n -e 's/^lanes-edit: unreadable log: \(.*\) — git could not read it.*$/\1/p' "$SE_WARN_FILE" 2>/dev/null)" || wr_urc=1 + wr_ulogs="$(sed -n -e 's/^lanes-edit: unreadable log: \(.*\) — git could not read it.*$/\1/p' -e 's/^lanes-edit: unreadable log: \(.*\) — the file could not be read.*$/\1/p' "$SE_WARN_FILE" 2>/dev/null)" || wr_urc=1 # THE EVENTS' OWN LIST OF THE LOGS (SHOULD-1(a)): where it failed, no # lane has the `dir` its trees are found under. wr_ulist="$(sed -n -e '/^lanes-edit: unlisted logs: /p' "$SE_WARN_FILE" 2>/dev/null)" || wr_urc=1 else wr_urc=1 fi + # A LANE WHOSE LOG WAS NOT READ HAS NO BINDING ANYBODY READ (#290), and + # where the list of the logs or the warnings of their read could not be + # had, no lane has one: liveness is pronounced for none of them below. + wr_unread_lanes=" $(printf '%s\n' "$wr_ulogs" | awk 'NF { n = $0; sub(/^.*\//, "", n); sub(/\.md$/, "", n); print tolower(n) }' | tr '\n' ' ')" + [ "$wr_urc" = 0 ] && [ -z "$wr_ulist" ] || wr_binding_unread=1 if [ "$wr_urc" != 0 ]; then wr_partial="${wr_partial:+$wr_partial; }whether every object log could be read is not known, because the warnings of their read could not be kept" else @@ -15183,6 +15331,11 @@ worktree_rows() { # [] *' '*) wr_pay="dir \"$wr_dir\"" ;; *) wr_pay="dir ${wr_dir:-none}" ;; esac + if [ -n "$wr_one" ] && [ "$wr_evrc" != 0 ] && [ -z "${LANES_LANE_STATE_ROOT:-}" ]; then + wr_out="${wr_out}UNREAD$US$WS$US$wr_l${US}its object log $wr_lp could not be read, so the directory its trees are recorded under is not known +" + continue + fi wr_root=""; wr_rrc=0 wr_root="$(lane_control_root "$wr_l" "$wr_pay" 2>/dev/null)" || wr_rrc=$? [ "$wr_rrc" = 0 ] || continue @@ -15230,6 +15383,10 @@ worktree_rows() { # [] # binding elsewhere has now. wr_live=0 [ "$wr_st" = LIVE ] && wr_live=1 + [ "$wr_live" = 1 ] || [ "$wr_reg_ok" = 1 ] || wr_live="" + [ "$wr_evrc" = 0 ] && [ "$wr_binding_unread" = 0 ] || wr_live="" + lower_into "$wr_l" + case "$wr_unread_lanes" in *" $LOWER_OUT "*) wr_live="" ;; esac [ "${wr_loc:-none}" = elsewhere ] && wr_live="" [ "$wr_live_ok" = 1 ] || wr_live="" wr_own=""; wr_orc=0 @@ -15329,6 +15486,10 @@ WR_TREES done </dev/null)" || lst_drc=$? + case "$lst_drc" in + 0) + case "$lst_dir" in + *' '*) lst_pay="dir \"$lst_dir\"" ;; + *) lst_pay="dir $lst_dir" ;; + esac ;; + 8) lst_pay="dir none" ;; + *) die "lane $lane's object log could not be read, so the directory its lifecycle snapshot is kept beside is NOT established. That is NOT 'this lane has no snapshot' — 8 says that, and a launcher answers an 8 by going on, over a snapshot nobody could look for (R22, Amendment 7(d)). Re-run once git can read the log." 10 ;; + esac + fi lst_out=""; lst_rc=0 - lst_out="$(lane_state_read "$lane")" || lst_rc=$? + lst_out="$(lane_state_read "$lane" "$lst_pay")" || lst_rc=$? case "$lst_rc" in 0) : ;; 8) exit 8 ;; @@ -18846,7 +19041,7 @@ EOF # WHICH OF THE 10s (#115): a record that opens and is not a whole # one of this schema is said as that, for the person who reads it — # and one nobody could look for (#291) is never said to be there. - lst_f="$(lane_control_root "$lane" 2>/dev/null || printf '')/lane-state.yaml" + lst_f="$(lane_control_root "$lane" "$lst_pay" 2>/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 @@ -19238,8 +19433,10 @@ EOF # # Local by default, as `lanes` is: `--fetch` refreshes the register first. # 0 rows (`--all`: with one PARTIAL line where the lane listing they were - # read through was not whole, #173) · 8 none · 2 a lane name refused - # (Amendment 15) · 64 usage · 1 a read failed + # read through was not whole, #173; a named lane: one UNREAD line where its + # object log, or the register its name is resolved by, could not be read, + # #290) · 8 none · 2 a lane name refused (Amendment 15) · 64 usage · 1 a + # read failed worktrees) wt_lane=""; wt_all=0; wt_index=0; wt_fetch=0 wt_usage="worktrees [ | --all] [--index] [--fetch]" @@ -19257,7 +19454,13 @@ EOF [ -z "$wt_lane" ] || check_lane_name "$wt_lane" [ "$wt_fetch" = 1 ] || LANES_NO_FETCH=1 log_sync + # THE REGISTER IS ASKED BEFORE THE NAME IS RESOLVED BY IT (#290). A read + # that succeeds is the one `canon_lane` and the listing then take, out of + # its cache; one that fails is not kept, and the name it could not resolve + # is read as not established - never as a lane with no trees. + wt_reg="" if [ -n "$wt_lane" ]; then + register_text_checked >/dev/null 2>&1 || wt_reg=register-unread wt_lane="$(canon_lane "$wt_lane")" || exit 2 # Amendment 15 fi if [ "$wt_index" = 1 ] && index_open worktrees; then @@ -19266,7 +19469,7 @@ EOF printf '%s\n' "$wt_out" else wt_out=""; wt_rc=0 - wt_out="$(worktree_rows "$wt_lane")" || wt_rc=$? + wt_out="$(worktree_rows "$wt_lane" "$wt_reg")" || wt_rc=$? case "$wt_rc" in 0) : ;; 8) exit 8 ;; diff --git a/lanes-index b/lanes-index index 577503e..e900220 100755 --- a/lanes-index +++ b/lanes-index @@ -1898,12 +1898,32 @@ 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 could not be read.""" + `(None, why)` where its generation could not be read. + + THE REASON IS THE HELPER'S OWN FIRST SENTENCE (opensoft/openRepoTools#290, + review r3 of #289's NIT-A). `lane-state` is 10 where the snapshot is there + and could not be read, and where the lane's object log could not be read, + so where the snapshot is kept is not known; the hold says which, in its + words. It said "its lifecycle snapshot could not be read" for both - and + a helper older than #290 said 8 for the second, which is read as 0 here + still: the hold that makes is a hold, for the reason it gives.""" proc = _helper_run(["lane-state", lane], 120, f"lane {lane}'s lifecycle snapshot") if proc.returncode in (1, 8): return 0, None if proc.returncode != 0: - return None, f"its lifecycle snapshot could not be read (lane-state exited {proc.returncode})" + # `die`'S OWN `(exit )` LINE IS NOT THE REASON (the review of #290, + # N3). The helper runs in this process's environment, and with + # `LANES_DEBUG` set `die` writes that line after the sentence, last. + lines = proc.stderr.strip().splitlines() + if lines and re.fullmatch(r"lanes-edit: \(exit [0-9]+\)", lines[-1].strip()): + lines.pop() + said = (lines or [""])[-1] + if said.startswith("lanes-edit: "): + said = said[len("lanes-edit: "):] + said = said.split(". ", 1)[0].rstrip(".") + return None, (f"its lifecycle generation could not be read - " + f"{said or 'lane-state said nothing more'} " + f"(lane-state exited {proc.returncode})") fields = dict(line.split("\t", 1) for line in proc.stdout.splitlines() if "\t" in line) if fields.get("state") == "UNKNOWN-SCHEMA": return None, f"its lifecycle snapshot records schema {fields.get('schema')}" diff --git a/tests/test_lanes_index.py b/tests/test_lanes_index.py index f2c9323..90481ad 100644 --- a/tests/test_lanes_index.py +++ b/tests/test_lanes_index.py @@ -2945,6 +2945,7 @@ def test_a_lanes_log_that_fails_after_the_listing_removes_no_row(inv): res = inv.sync(env={**by_dir, **shim}) calls = _shim_calls(log) assert calls and calls[0].startswith("1 "), calls # the listing's own read: not vacuous + assert len(calls) == 1, calls # and the lane's `dir` is not read again (NIT-B, #290) assert key in inv.rows(), (res.stdout, res.stderr, calls) assert dump(inv.e.sqlite, with_utc=True) == before, (res.stdout, res.stderr, calls) assert "kept as they are" not in res.stdout, (res.stdout, calls) # and no lane held over it diff --git a/tests/test_named_read_unreadable_290.py b/tests/test_named_read_unreadable_290.py new file mode 100644 index 0000000..333004f --- /dev/null +++ b/tests/test_named_read_unreadable_290.py @@ -0,0 +1,569 @@ +# SPDX-License-Identifier: Apache-2.0 +"""A READ THAT COULD NOT BE MADE IS NEVER "NONE" (opensoft/openRepoTools#290). + +#289 made the whole-listing read (`worktrees --all`, the one the inventory +index syncs from) refuse a listing that is not whole. These are the reads +beside it that still answered a failed read as an answer: + + * a NAMED read (`worktrees `, `lanes --worktrees `) whose one + read of the lane's log failed said "none" (exit 8) - the log gives the + lane's `dir`, its trees are under the root that `dir` places, and the root + fell to `$PROJECTS_ROOT/.lane-state` (#289's review r1, SHOULD-3); + * a named read with the register unreadable resolved the name as typed, so + a name typed in another case found no root and said "none" too, and a + tree's writer read `0` where the register's ids were never read; + * the `lanes` listing said nothing where the list of the logs could not be + made, and an empty listing over reads that all failed said "no lane"; + * with no `origin`, one log file that would not open left no warning - and + `mawk`, which stops at the first file it cannot open, dropped every log + after it as well; + * `lanes-index` read a failed read of a lane's log in `lane-state` as + generation 0, and held the lane for that (review r3's NIT-A). + +And the guards the fixes for those need: a named read reads its lane's log +ONCE, and finds the trees by that read; a tree whose lane's log - its binding +- was not read has no writer pronounced, named or in the whole listing, and +none has where the events' own list of the logs failed; a row whose log could +not be listed is never classed DORMANT; and `lane-state`'s 10 for a snapshot it +could not read names the file its one read of the log placed. The hold +`lanes-index` makes over that 10 gives `lane-state`'s sentence with +`LANES_DEBUG` set too, not the `(exit 10)` line `die` adds after it. And +`lane-state` hands the `dir` it read on as `lane-start` writes one, quoted +only where it holds a space, so a bare `dir` holding a `"` is read whole, as +on main. + +Every case runs on `test_lanes_index`'s own estate and its helpers. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from test_lanes_index import (HEADS, LANES, SHOW_LOG_B1, US, UUID_B1, + _a_lane_found_through_its_log, _git_shim, _row, _shim_calls, + _sidecar, _unreadable, _wt, inv) + +__all__ = ["inv"] # the fixture, imported so that pytest finds it here + +LOG_B1 = "origin/main:lanes/log/repoB-1.md" +REGISTER = "origin/main:lanes/LANES.md" +ARCHIVE = "origin/main:lanes/archive/LANES-retired.md" +LOG_TREE = "origin/main:lanes/log" +SHOW_REGISTER = "show origin/main:lanes/LANES.md " +LIST_LOGS = "ls-tree --name-only origin/main -- lanes/log/ " +NO_GIT = {"LANES_NO_GIT": "1"} + + +def _lines(stdout: str) -> list[list[str]]: + return [line.split(US) for line in stdout.splitlines() if line] + + +def _no_dead_writer(lines: list[list[str]]) -> None: + """No TREE line pronounces its writer `0` - "nobody is writing there".""" + for fields in lines: + if fields[0] == "TREE": + assert fields[10] != "0", fields + + +# ------------------------------------------------- SHOULD-3: the lane's log + + +def test_a_named_read_whose_log_could_not_be_read_says_so_and_not_none(inv): + """THE NAMED READ'S ONE READ OF THE LANE'S LOG (the review of #289, + SHOULD-3, its probe P4). Where no override names a control root, a lane's + trees are under the root its log's `dir` places, and a named read reads + that log once, through `lanes_rows --lane`. A published log git could not + read gave no `dir`, the root fell to `$PROJECTS_ROOT/.lane-state`, and the + read said "none": exit 8, and `lanes --worktrees` said "no worktree is + inventoried". It is now ONE `UNREAD` line naming the log, exit 0, which + `lanes --worktrees` prints as NOT READ. The `lanes --lane` read of the same + lane - the one `lane ` makes - still answers, and says it. A log + tree that could not be listed is the same `UNREAD` line.""" + by_dir, key = _a_lane_found_through_its_log(inv) + whole = inv.e.edit("worktrees", "repoB-1", env=by_dir) + assert whole.returncode == 0, whole.stderr + assert [f[0] for f in _lines(whole.stdout)] == ["TREE"], whole.stdout # not vacuous + with _unreadable(inv.e, LOG_B1): + res = inv.e.edit("worktrees", "repoB-1", env=by_dir) + assert res.returncode == 0, (res.returncode, res.stdout, res.stderr) + lines = _lines(res.stdout) + assert len(lines) == 1 and lines[0][:3] == ["UNREAD", "Eagle", "repoB-1"], res.stdout + assert "its object log lanes/log/repoB-1.md could not be read" in lines[0][3], lines[0] + listed = _wt(inv, "repoB-1", env=by_dir) + assert listed.returncode == 0, (listed.stdout, listed.stderr) + assert "inventory NOT READ: its object log lanes/log/repoB-1.md could not be read" \ + in listed.stdout, listed.stdout + assert "no worktree is inventoried" not in listed.stdout, listed.stdout + row = inv.e.edit("lanes", "--lane", "repoB-1", env=by_dir) + assert row.returncode == 0, (row.stdout, row.stderr) + assert row.stdout.startswith("repoB-1\t"), row.stdout + assert "these published logs could not be read, so no lane of theirs is classed " \ + "DORMANT: lanes/log/repoB-1.md" in row.stderr, row.stderr + # AND A LOG TREE THAT COULD NOT BE LISTED (the review of #290, SHOULD-1(b)). + # `git show` fails there as it does for a path the tree does not name, and + # the `ls-tree` of the log's path that tells the two apart fails as well: + # that is a read that did not happen, never a lane with no log. + with _unreadable(inv.e, LOG_TREE): + res = inv.e.edit("worktrees", "repoB-1", env=by_dir) + assert res.returncode == 0, (res.returncode, res.stdout, res.stderr) + lines = _lines(res.stdout) + assert len(lines) == 1 and lines[0][:3] == ["UNREAD", "Eagle", "repoB-1"], res.stdout + assert "its object log lanes/log/repoB-1.md could not be read" in lines[0][3], lines[0] + + +def test_a_named_read_of_a_lane_with_no_published_log_still_says_none(inv): + """A LOG THAT IS NOT THERE IS NOT ONE THAT COULD NOT BE READ. repoC-1 has a + row and no log: `git show` of its log fails because origin has no such + path, and the tree says so - the read is the 8 it always was, and a lane + with a tree of its own lists it. + + ASKED WITH NO OVERRIDE NAMING THE CONTROL ROOT, as a workstation asks it + (the review of #290, SHOULD-1(a)). The fixture's `LANES_LANE_STATE_ROOT` + places every lane's root without its log, so the named read never reaches + its `UNREAD` line and `lane-state` never reads the log: a read that took + "no such path" for "could not be read" passed under it. Without it the + named read is still 8 and empty, `lanes --lane` says nothing of a log that + could not be read, and `lane-state` is 8 - never the 10 of a log it could + not read, which `lanes-index` holds a lane for.""" + no_root = {"LANES_LANE_STATE_ROOT": None} + res = inv.e.edit("worktrees", "repoC-1", env=no_root) + assert res.returncode == 8, (res.returncode, res.stdout, res.stderr) + assert res.stdout == "", res.stdout + row = inv.e.edit("lanes", "--lane", "repoC-1", env=no_root) + assert row.returncode == 0 and row.stdout.startswith("repoC-1\t"), (row.stdout, row.stderr) + assert "could not be read" not in row.stderr, row.stderr + state = inv.e.edit("lane-state", "repoC-1", env=no_root) + assert state.returncode == 8, (state.returncode, state.stdout, state.stderr) + _sidecar(inv.lsr, "repoC-1", HEADS["repoC-1"]) + res = inv.e.edit("worktrees", "repoC-1") + assert res.returncode == 0, res.stderr + assert [f[:3] for f in _lines(res.stdout)] == [["TREE", "Eagle", "repoC-1"]], res.stdout + + +@pytest.mark.parametrize("what,mode", [("dir", 0o000), ("dir", 0o400), ("file", 0o000)], + ids=["dir-000", "dir-400", "file-000"]) +def test_a_named_read_with_no_origin_whose_log_will_not_open_says_so(inv, what, mode): + """THE SAME READ WITH NO `origin` (`LANES_NO_GIT=1`): the lane's log is this + checkout's file. A log directory that cannot be searched hid the file - + the read found no log, no `dir`, and said "none" - and a log file that + would not open did the same through the parser's own status, which the + listing threw away.""" + by_dir, key = _a_lane_found_through_its_log(inv) + env = {**by_dir, **NO_GIT} + whole = inv.e.edit("worktrees", "repoB-1", env=env) + assert whole.returncode == 0 and "TREE" + US in whole.stdout, whole.stdout # not vacuous + logdir = inv.e.wip / "lanes" / "log" + target = logdir if what == "dir" else logdir / "repoB-1.md" + before = target.stat().st_mode & 0o777 + target.chmod(mode) + try: + if what == "dir" and os.access(logdir, os.R_OK) and os.access(logdir, os.X_OK) \ + or what == "file" and os.access(target, os.R_OK): + pytest.skip(f"this account reads a mode-{mode:03o} {what} (root): nothing to prove") + res = inv.e.edit("worktrees", "repoB-1", env=env) + assert res.returncode == 0, (res.returncode, res.stdout, res.stderr) + lines = _lines(res.stdout) + assert len(lines) == 1 and lines[0][:3] == ["UNREAD", "Eagle", "repoB-1"], res.stdout + assert "its object log lanes/log/repoB-1.md could not be read" in lines[0][3], lines[0] + finally: + target.chmod(before) + + +# ------------------------------------------- the register, for a named read + + +def test_a_named_read_with_the_register_unreadable_never_says_none(inv): + """THE REGISTER SPELLS THE LANE, AND ITS IDS SAY WHO WRITES (#289's writer's + items, filed on #290). With the register unreadable, `canon_lane` hands + back the name as typed: typed in another case, it named a control root + that is not there, and the read said "none". Where the register could not + be read and no tree was found, that is now an `UNREAD` line naming the + register - a case-folding filesystem finds the trees instead - and a tree + that is found is never pronounced writer `0`: whether a writer holds it is + the register's ids, which nobody read.""" + folds = (inv.lsr / "REPOA-1").is_dir() + with _unreadable(inv.e, REGISTER): + other = inv.e.edit("worktrees", "REPOA-1") + assert other.returncode == 0, (other.returncode, other.stdout, other.stderr) + lines = _lines(other.stdout) + _no_dead_writer(lines) + if not folds: + assert len(lines) == 1 and lines[0][:3] == ["UNREAD", "Eagle", "REPOA-1"], \ + other.stdout + assert "the register lanes/LANES.md could not be read" in lines[0][3], lines[0] + listed = _wt(inv, "REPOA-1") + assert listed.returncode == 0, (listed.stdout, listed.stderr) + assert "no worktree is inventoried" not in listed.stdout, listed.stdout + same = inv.e.edit("worktrees", "repoA-1") + assert same.returncode == 0, same.stderr + trees = [f for f in _lines(same.stdout) if f[0] == "TREE"] + assert len(trees) == 2, same.stdout + assert [f[10] for f in trees] == ["", ""], trees # unknown, never `0` + + +def test_a_named_read_answers_by_the_register_read_made_before_its_name(inv): + """THE REGISTER READ THE NAME WAS RESOLVED BY IS THE ONE THE READ ANSWERS BY. + A register read that succeeds is kept for the process; one that fails is + asked again. Here the read made before the name was resolved fails, and so + does `canon_lane`'s own, which hands back `REPOA-1` as typed - and the + next read succeeds: a read that asked the register again after the name + was resolved took the register for read, looked under a root no lane has, + and said "none".""" + env, log = _git_shim(inv, SHOW_REGISTER, first=1, last=2) + res = inv.e.edit("worktrees", "REPOA-1", env=env) + calls = _shim_calls(log) + assert len(calls) >= 3, calls # two reads failed and a later one read: not vacuous + assert res.returncode == 0, (res.returncode, res.stdout, res.stderr, calls) + lines = _lines(res.stdout) + _no_dead_writer(lines) + assert lines and all(f[2].lower() == "repoa-1" for f in lines), res.stdout + + +def test_the_whole_listing_with_the_register_unreadable_pronounces_no_writer_dead(inv): + """AND IN THE WHOLE LISTING TOO: it is `PARTIAL` there, and the sync + writes nothing over it, but `lanes --worktrees` prints its rows - and a + writer whose lane's ids the register held is unknown, not `0`. A writer + found LIVE through this checkout's own copy of the row stays LIVE: that is + a session record naming one of the lane's ids, read.""" + with _unreadable(inv.e, REGISTER): + res = inv.e.edit("worktrees", "--all") + assert res.returncode == 0, res.stderr + lines = _lines(res.stdout) + assert any(f[0] == "PARTIAL" for f in lines), res.stdout + _no_dead_writer(lines) + live = {f[4]: f[10] for f in lines if f[0] == "TREE"} + assert live[str(inv.a2_tree)] == "1", live + assert live[str(inv.run_tree)] == "" and live[str(inv.dirty_tree)] == "", live + + +# --------------------------------------------- the `lanes` listing itself + + +def test_the_lane_listing_says_when_the_list_of_the_logs_could_not_be_made(inv): + """THE LISTING ITSELF, NOT `--worktrees` (#289's writer's items, on #290). + The lanes are named from the register, the archive and the list of the + logs, and the logs' lines are read through a second list of them. Where + both lists failed, the listing said nothing: a lane with a log and no row + was simply missing, and no row showed what its log says. It says both + now - and that no row is classed DORMANT over it, the words `lane-end + --retire-dormant` refuses on.""" + env, log = _git_shim(inv, LIST_LOGS, first=1) + res = inv.e.edit("lanes", "--all", env=env) + assert len(_shim_calls(log)) >= 2, _shim_calls(log) # both lists failed: not vacuous + assert res.returncode == 0, (res.stdout, res.stderr) + assert "repoA-1\t" in res.stdout and "repoB-1\t" not in res.stdout, res.stdout + assert "the object logs under origin/main:lanes/log/ could not be listed, so a lane " \ + "with a log and no row in the register or the archive is missing from this listing" \ + in res.stderr, res.stderr + assert "could not be listed for their lines" in res.stderr, res.stderr + assert "no row is classed DORMANT here" in res.stderr, res.stderr + + +def test_a_lane_listing_its_failed_reads_left_empty_is_not_none(inv): + """AN EMPTY LISTING OVER READS THAT FAILED IS NOT "NO LANE". With the + register, the archive and the list of the logs all unreadable, the listing + named no lane and said so as 8 - and `lanes` printed "no lane is recorded" + - before any of its notes. Each read that failed is said, and the read is + 1: the listing could not be read.""" + with _unreadable(inv.e, ARCHIVE, REGISTER, LOG_TREE): + res = inv.e.edit("lanes", "--all") + assert res.returncode == 1, (res.returncode, res.stdout, res.stderr) + assert res.stdout == "", res.stdout + for said in ("the register (lanes/LANES.md, published or this checkout's) could not " + "be read", + "lanes/archive/LANES-retired.md exists and could not be read", + "the object logs under origin/main:lanes/log/ could not be listed"): + assert said in res.stderr, (said, res.stderr) + wrapped = inv.e.run("bash", LANES, "--all") + assert wrapped.returncode == 1, (wrapped.returncode, wrapped.stdout, wrapped.stderr) + assert "no lane is recorded" not in wrapped.stdout, wrapped.stdout + + +# ------------------------------------------ no `origin`, one log file + + +def test_an_unreadable_log_file_with_no_origin_is_said_and_drops_no_other(inv): + """WITH NO `origin` THE LOGS ARE THIS CHECKOUT'S FILES, read in one parser + run - and one that would not open left no warning naming it: the listing + took it for a lane with no log and the inventory's read for whole. `mawk` + (Debian's and Ubuntu's `awk`) ends at the first file it cannot open, so + every log after it was gone as well. The file is said now, by name - the + inventory's read is `PARTIAL` over it - and the logs after it are read.""" + log = inv.e.wip / "lanes" / "log" / "repoA-2.md" + log.chmod(0) + try: + if os.access(log, os.R_OK): + pytest.skip("this account reads a mode-000 file (root): nothing to prove here") + res = inv.e.edit("worktrees", "--all", env=NO_GIT) + assert res.returncode == 0, res.stderr + partial = [f for f in _lines(res.stdout) if f[0] == "PARTIAL"] + assert len(partial) == 1, res.stdout + assert "these object logs could not be read: lanes/log/repoA-2.md" in partial[0][2], \ + partial + listed = inv.e.edit("lanes", "--all", env=NO_GIT) + assert listed.returncode == 0, listed.stderr + assert "these published logs could not be read, so no lane of theirs is classed " \ + "DORMANT: lanes/log/repoA-2.md" in listed.stderr, listed.stderr + b1 = [row for row in listed.stdout.splitlines() if row.startswith("repoB-1\t")] + assert len(b1) == 1 and b1[0].split("\t")[1] == "IDLE", listed.stdout # read, after it + finally: + log.chmod(0o644) + + +# ------------------------------------- NIT-A: `lanes-index`'s generation read + + +def test_a_generation_read_whose_log_failed_says_so_and_holds(inv): + """THE SYNC ASKS `lane-state` FOR THE GENERATION OF A LANE WHOSE ROWS WOULD + ALL LEAVE (review r3 of #289, NIT-A). That read worked the lane's root out + again from its log and threw a failed `git show` away: `lane-state` said + 8, read as generation 0, and the sync held the lane because its + "generation reads 0 and its rows were written at 1" - a hold for a reason + that was not the one. `lane-state` now reads the lane's `dir` once, with + its status, and a log it could not read is its 10 - the record is there + and could not be read, never the 8 of none - and the hold says so. A + `reconcile` over the same read keeps the rows too, for the same reason, + and the first read that succeeds takes the lane as it stands.""" + by_dir, key = _a_lane_found_through_its_log(inv) + put = inv.e.edit("set-lane-state", "repoB-1", "RUNNING", "--operation", "op-test-b1", + env=by_dir) + assert put.returncode == 0, put.stderr + assert inv.sync(env=by_dir).returncode == 0 + assert inv.rows()[key]["generation"] == 1, inv.rows()[key] # rows written at 1 + with _unreadable(inv.e, LOG_B1): + state = inv.e.edit("lane-state", "repoB-1", env=by_dir) + assert state.returncode == 10, (state.returncode, state.stdout, state.stderr) + assert "lane repoB-1's object log could not be read" in state.stderr, state.stderr + for sidecar in (inv.root / "elsewhere" / ".lane-state" / "repoB-1" / "trees").iterdir(): + sidecar.unlink() # its rows would all leave: the sync asks for its generation + why = "its lifecycle generation could not be read - lane repoB-1's object log could not " \ + "be read" + shim, log = _git_shim(inv, SHOW_LOG_B1, first=2, last=2) + res = inv.sync(env={**by_dir, **shim}) + calls = _shim_calls(log) + assert len(calls) == 2, calls # the listing's read, then `lane-state`'s: not vacuous + assert res.returncode == 0, (res.stdout, res.stderr) + assert key in inv.rows(), (res.stdout, calls) # a refused hold stays a hold + assert "generation reads 0" not in res.stdout, res.stdout + assert f"lane repoB-1: its rows are kept as they are - {why}" in res.stdout, res.stdout + shim, log = _git_shim(inv, SHOW_LOG_B1, first=2, last=2) + rec = inv.e.index("reconcile", "--source", "inventory", env={**by_dir, **shim}) + assert len(_shim_calls(log)) == 2, _shim_calls(log) + assert rec.returncode == 0, (rec.stdout, rec.stderr) + assert "went back from 1 to 0" not in rec.stdout, rec.stdout + assert f"lane repoB-1: its rows are kept as they are - {why}" in rec.stdout, rec.stdout + assert key in inv.rows(), rec.stdout + healed = inv.sync(env=by_dir) + assert healed.returncode == 0, healed.stderr + assert "0 upserted, 1 removed" in healed.stdout, healed.stdout + assert key not in inv.rows(), healed.stdout + + +def test_a_generation_hold_gives_lane_states_sentence_with_lanes_debug_set(inv): + """THE HOLD'S REASON IS `lane-state`'S SENTENCE, NOT ITS CODE (the review of + #290, N3). The reason is the last line `lane-state` wrote to stderr, and + the indexer runs the helper in its own environment: with `LANES_DEBUG` + set, `die` writes `lanes-edit: (exit 10)` after the sentence, and the hold + read "could not be read - (exit 10)". That line is not the reason.""" + by_dir, key = _a_lane_found_through_its_log(inv) + put = inv.e.edit("set-lane-state", "repoB-1", "RUNNING", "--operation", "op-test-b1", + env=by_dir) + assert put.returncode == 0, put.stderr + assert inv.sync(env=by_dir).returncode == 0 + assert inv.rows()[key]["generation"] == 1, inv.rows()[key] # rows written at 1 + for sidecar in (inv.root / "elsewhere" / ".lane-state" / "repoB-1" / "trees").iterdir(): + sidecar.unlink() # its rows would all leave: the sync asks for its generation + shim, log = _git_shim(inv, SHOW_LOG_B1, first=2, last=2) + res = inv.sync(env={**by_dir, **shim, "LANES_DEBUG": "1"}) + assert len(_shim_calls(log)) == 2, _shim_calls(log) # `lane-state`'s read failed + assert res.returncode == 0, (res.stdout, res.stderr) + assert key in inv.rows(), res.stdout + assert "lane repoB-1: its rows are kept as they are - its lifecycle generation could " \ + "not be read - lane repoB-1's object log could not be read" in res.stdout, res.stdout + assert "(exit 10)" not in res.stdout, res.stdout + + +# --------------------------------------- the named read reads its log once + + +def test_a_named_read_reads_its_lanes_log_once_and_finds_its_trees_by_it(inv): + """ONE READ OF THE LANE'S LOG, AND IT IS THE ONE ITS TREES ARE FOUND BY + (#290). The named read reads the log with its status, and the listing it + asks for the lane's row takes that read from it (`PIN_LANE_LOG`) instead of + making a second one: a second read that failed where the first had not gave + the row no `dir`, the root fell to the third rung, and the read said "none" + over the trees the first read had placed.""" + by_dir, key = _a_lane_found_through_its_log(inv) + shim, log = _git_shim(inv, SHOW_LOG_B1, first=2) + res = inv.e.edit("worktrees", "repoB-1", env={**by_dir, **shim}) + calls = _shim_calls(log) + assert len(calls) == 1, calls # read once, and so never by the read that fails + assert res.returncode == 0, (res.returncode, res.stdout, res.stderr, calls) + assert [f[:3] for f in _lines(res.stdout)] == [["TREE", "Eagle", "repoB-1"]], res.stdout + + +# ------------------------------------------------ a binding nobody read + + +@pytest.mark.parametrize("named", [True, False], ids=["named", "whole"]) +def test_a_tree_whose_lanes_log_was_not_read_has_no_writer_pronounced(inv, named): + """THE LANE'S LOG IS ITS BINDING, AND A BINDING NOBODY READ IS NOT ONE THAT + IS HERE (#290, Amendment 18(b)). A writer of `0` says that no live record + here names one of the lane's ids AND that its binding is here or nowhere; + with the lane's log unread the second half was never read - the lane may be + bound on another host - so the writer is unknown, as it is for a lane + bound elsewhere. An override naming the control root still finds the + trees, so they are listed. A lane whose log was read keeps its own answer + beside it: repoA-2 is still LIVE.""" + with _unreadable(inv.e, "origin/main:lanes/log/repoA-1.md"): + res = inv.e.edit("worktrees", *(["repoA-1"] if named else ["--all"])) + assert res.returncode == 0, (res.returncode, res.stdout, res.stderr) + lines = _lines(res.stdout) + a1 = {f[4]: f[10] for f in lines if f[0] == "TREE" and f[2] == "repoA-1"} + assert a1 == {str(inv.run_tree): "", str(inv.dirty_tree): ""}, lines + if not named: + assert any(f[0] == "PARTIAL" for f in lines), lines # and the listing is not whole + a2 = {f[4]: f[10] for f in lines if f[0] == "TREE" and f[2] == "repoA-2"} + assert a2 == {str(inv.a2_tree): "1"}, lines + + +def test_a_listing_whose_logs_could_not_be_listed_pronounces_no_writer(inv): + """AND WHERE THE EVENTS' OWN LIST OF THE LOGS COULD NOT BE MADE, NO LANE'S + BINDING WAS READ (#290). The listing's first list of the logs is pinned and + names the lanes; the events list them again for their lines, and where that + list failed the read is `PARTIAL` - and no tree in it has a writer + pronounced, LIVE included, because a lane bound on another host is + unknown from here whatever a local record says (Amendment 18(b)).""" + shim, log = _git_shim(inv, LIST_LOGS, first=2) + res = inv.e.edit("worktrees", "--all", env=shim) + assert len(_shim_calls(log)) == 2, _shim_calls(log) # the check's list, then the events' + assert res.returncode == 0, (res.returncode, res.stdout, res.stderr) + lines = _lines(res.stdout) + assert any(f[0] == "PARTIAL" and "could not be listed" in f[2] for f in lines), lines + writers = {f[4]: f[10] for f in lines if f[0] == "TREE"} + assert str(inv.a2_tree) in writers and len(writers) >= 3, lines # not vacuous + assert set(writers.values()) == {""}, writers + + +# ---------------------------------- the DORMANT class over a failed log list + +UUID_D1 = "dddddddd-0002-4000-8000-00000000d002" + + +def test_a_row_whose_log_could_not_be_listed_is_not_classed_dormant(inv): + """A ROW WITH A PUBLISHED LOG IS NOT A ROW WITH NO LOG BECAUSE THE LIST OF + THE LOGS FAILED (#290). DORMANT is a row with no object log, asked of the + events, and where their own list of the logs could not be made no lane has + a line in them: a row whose log is on `origin` and not yet in this + checkout, with an `ENDED` cell, was classed DORMANT - left out of the + listing and offered to `lane-end --retire-dormant`. It is listed now, as + the listing's note says, and its class is withheld.""" + lanes = inv.e.wip / "lanes" + reg = lanes / "LANES.md" + rows = reg.read_text(encoding="utf-8").split("\n") + at = next(i for i, line in enumerate(rows) if line.startswith("| `repoC-1`")) + rows.insert(at + 1, _row("repoD-1", f"`{UUID_D1}`", "ENDED · 2026-09-03T00:00:00Z · done", + started="2026-09-03")) + reg.write_text("\n".join(rows), encoding="utf-8") + dlog = lanes / "log" / "repoD-1.md" + dlog.write_text(f"STARTED — lane repoD-1, session {UUID_D1}@Eagle, 2026-09-03T00:00:00Z, " + f"lane:repoD-1 → home opensoft/repoD; dir /work/repoD\n", encoding="utf-8") + inv.e._git("add", "-A") + inv.e._git("commit", "-q", "-m", "repoD-1: a row and a log") + inv.e._git("push", "-q", "origin", "main") + dlog.unlink() # published, and not in this checkout + whole = inv.e.edit("lanes", "--all") + assert whole.returncode == 0, whole.stderr + d1 = [row.split("\t") for row in whole.stdout.splitlines() if row.startswith("repoD-1\t")] + assert len(d1) == 1 and d1[0][1] == "IDLE" and d1[0][13] == "none", whole.stdout # not vacuous + env, log = _git_shim(inv, LIST_LOGS, first=1) + res = inv.e.edit("lanes", "--all", env=env) + assert len(_shim_calls(log)) >= 2, _shim_calls(log) + assert res.returncode == 0, (res.stdout, res.stderr) + d1 = [row.split("\t") for row in res.stdout.splitlines() if row.startswith("repoD-1\t")] + assert len(d1) == 1 and d1[0][13] == "none", (res.stdout, res.stderr) + + +# -------------------------- `lane-state`'s own 10 names the file it found + + +def test_an_unreadable_snapshot_is_named_where_the_lanes_one_read_placed_it(inv): + """THE 10 OF A SNAPSHOT THAT COULD NOT BE READ NAMES THAT SNAPSHOT (#290). + `lane-state` reads the lane's `dir` once and finds the snapshot by it; the + message for a snapshot it could not read worked the root out again, + reading the log a second time, and where that read failed it named a file + the third rung places - not the one that could not be read.""" + by_dir, key = _a_lane_found_through_its_log(inv) + put = inv.e.edit("set-lane-state", "repoB-1", "RUNNING", "--operation", "op-test-b1", + env=by_dir) + assert put.returncode == 0, put.stderr + snap = inv.root / "elsewhere" / ".lane-state" / "repoB-1" / "lane-state.yaml" + assert snap.is_file(), snap # where the log's `dir` places it: not vacuous + snap.chmod(0) + try: + if os.access(snap, os.R_OK): + pytest.skip("this account reads a mode-000 file (root): nothing to prove here") + shim, log = _git_shim(inv, SHOW_LOG_B1, first=2) + res = inv.e.edit("lane-state", "repoB-1", env={**by_dir, **shim}) + assert len(_shim_calls(log)) == 1, _shim_calls(log) + assert res.returncode == 10, (res.returncode, res.stdout, res.stderr) + assert f"Read it by hand: {snap}" in res.stderr, res.stderr + finally: + snap.chmod(0o644) + + +# ----------------- `lane-state` hands its `dir` on as `lane-start` writes one + + +def _a_lane_resumed_under(inv, parent: str) -> tuple[dict, Path]: + """repoB-1 RESUMED in `//repoB`, its `dir` spelled as + `lane-start`'s `quote_subfield` spells one - quoted where it holds a + space, bare where it does not - and taken RUNNING by `set-lane-state`, + which files the snapshot under the root the log's own read of that `dir` + places. No override names a control root. Returns that environment and + the snapshot.""" + checkout = inv.root / parent / "repoB" + checkout.mkdir(parents=True) + spelled = f'"{checkout}"' if " " in str(checkout) else str(checkout) + log = inv.e.wip / "lanes" / "log" / "repoB-1.md" + log.write_text(log.read_text(encoding="utf-8") + + f"RESUMED — lane repoB-1, session {UUID_B1}@Eagle, 2026-10-05T00:00:00Z, " + f"lane:repoB-1 → dir {spelled}; profile team-02a\n", encoding="utf-8") + inv.e._git("add", "-A") + inv.e._git("commit", "-q", "-m", "repoB-1 resumed in a checkout of its own") + inv.e._git("push", "-q", "origin", "main") + by_dir = {"LANES_LANE_STATE_ROOT": None} + put = inv.e.edit("set-lane-state", "repoB-1", "RUNNING", "--operation", "op-test-b1", + env=by_dir) + assert put.returncode == 0, put.stderr + return by_dir, checkout.parent / ".lane-state" / "repoB-1" / "lane-state.yaml" + + +@pytest.mark.parametrize("parent", ['q"uote', "My Projects", 'tab\tq"uote'], + ids=["quote-mark", "space", "tab-and-quote"]) +def test_lane_state_hands_the_dir_it_read_on_as_lane_start_writes_it(inv, parent): + """THE `dir` GOES ON QUOTED ONLY WHERE IT HOLDS A SPACE (#290, by Brett + Heap's ruling for #295, "quote only where a space is"). `lane-state` reads + the lane's `dir` once and hands it to the snapshot's read as a payload. It + quoted every value there, and a quoted value ends at its first `"`: the + bare `/q"uote/repoB`, which `lane-start` refuses to write and a line + written by hand can hold, came back as `/q`. That one's parent is a + directory, so the read looked under `/.lane-state` and said 8, "no + snapshot", over the one `set-lane-state` had filed and main reads. A `dir` + holding a space is still read whole: handed on bare, it would end at the + space and look in the same wrong place. The rule is a space and not any + whitespace: a bare `dir` holding a TAB and a `"` and no space is read whole + as well, as on main.""" + by_dir, snap = _a_lane_resumed_under(inv, parent) + named = inv.e.edit("lane-dir", "repoB-1", env=by_dir) + assert named.returncode == 0, (named.returncode, named.stdout, named.stderr) + assert named.stdout.strip() == str(inv.root / parent / "repoB"), named.stdout # read whole + assert snap.is_file(), snap # filed where the whole `dir` places it + assert not (inv.root / ".lane-state").exists() # where a cut `dir` looks: not vacuous + res = inv.e.edit("lane-state", "repoB-1", env=by_dir) + assert res.returncode == 0, (res.returncode, res.stdout, res.stderr) + read = dict(line.split("\t", 1) for line in res.stdout.splitlines() if "\t" in line) + assert (read.get("state"), read.get("file")) == ("RUNNING", str(snap)), res.stdout