diff --git a/README.md b/README.md index 4256a24..b7d72d4 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,17 @@ Twenty-six until **Amendment 16** (ratified the same day) put `lane-rename` on the handoff and `lanes/aliases.tsv` — and its old name resolves for ever afterwards, in every reader that takes a lane name. +**And it writes down what it placed.** A RECEIPT (#57) — one row of +` ` per regular file placed, in +`${OPENREPOTOOLS_DATA_DIR:-${XDG_DATA_HOME:-~/.local/share}/openRepoTools}/installed.tsv`, +mode 0600, replaced whole through a temporary — is read by a later `--install` +before it RETIRES a word. A digest that still matches is this installer's copy +and is removed; one that has MOVED, or a row it cannot check without a digest +tool, is named and left; no row means the `Installed on PATH by` header. Rows +are keyed by absolute DESTINATION, so moving `$OPENREPOTOOLS_BIN_DIR` keeps the +old evidence; hook entries and the receipt get no row. A receipt it cannot write +is ONE LINE saying so, never a refused install. + Run from a checkout it copies the files beside it and needs no network and no `gh` at all; run from stdin, as above, it fetches all of them at the same ref. The API is tried before the raw URL, because `gh` is authenticated and works @@ -323,6 +334,7 @@ where `raw.githubusercontent.com` is blocked. | `$OPENREPOTOOLS_REPO` | `opensoft/openRepoTools` | the `owner/name` to fetch from — a fork or a mirror, named once | | `$OPENREPOTOOLS_REF` | `main` | the ref to fetch it at | | `$OPENREPOTOOLS_BIN_DIR` | `~/.local/bin` | where `--install` puts the thirteen | +| `$OPENREPOTOOLS_DATA_DIR` | `${XDG_DATA_HOME:-~/.local/share}/openRepoTools` | where `--install` writes the receipt of what it placed | | `$AGENT_PROTOCOL_ROOT` | `~/.agents` | where `workspace.yaml` lives — the one pointer to your data | | `$CLAUDE_PROFILES_HOME` | `~/.claude-profiles` | the profiles root `--install` places the shared skills under | | `$LANES_WORKSTATION` | — | this workstation's name, exported by the workBenches launcher. Outside a container it defaults to `hostname -s`; **inside one with no value every writer refuses**, because a container id is not a workstation and the log is never rewritten (Amendment 11, decision 8(d)) | diff --git a/openRepoTools b/openRepoTools index d7f07ab..7807f89 100755 --- a/openRepoTools +++ b/openRepoTools @@ -142,6 +142,16 @@ refuses a prompt whose window, session name and register row are not one lane every `UserPromptSubmit` hook of your own exactly where it is). It never writes a profile's own settings.json: the launcher owns that one. +It then writes a RECEIPT of the files it placed — one row of +` ` each — at +`${OPENREPOTOOLS_DATA_DIR:-${XDG_DATA_HOME:-~/.local/share}/openRepoTools}/installed.tsv`, +mode 0600. That is how a later `--install` knows that a command it is about to +RETIRE is a copy it wrote rather than a file of yours that happens to carry the +same header: a digest that still matches is removed, one that has moved — or a +row it cannot check, for want of a digest tool — is named and left for you. A +receipt it cannot write is one line on this terminal and never a refused +install. + `/restart` is the INSIDE half and has to be a skill: a running session cannot `exec` a launcher over itself, so it binds the window, stamps the record through `lane-start --no-launch` and prints — never a picker (Amendment 11 @@ -171,6 +181,9 @@ Speckit git extension's scripts. `status` only reads. $OPENREPOTOOLS_REPO owner/name to fetch from (default opensoft/openRepoTools) $OPENREPOTOOLS_REF the ref to fetch it at (default main) $OPENREPOTOOLS_BIN_DIR where --install puts them (default ~/.local/bin) + $OPENREPOTOOLS_DATA_DIR where the receipt goes (default + $XDG_DATA_HOME/openRepoTools) + $XDG_DATA_HOME the data root it falls back to (default ~/.local/share) $AGENT_PROTOCOL_ROOT where workspace.yaml lives (default ~/.agents) $CLAUDE_PROFILES_HOME the profiles root (default ~/.claude-profiles) $PROJECTS_DIR your projects directory (default ~/projects) @@ -267,6 +280,524 @@ fetch_from_repo() { # session's own name are not a register writer's to touch (clause (f)). INSTALLABLES=(openRepoTools park resume status lane lanes lane-handoff lane-rename lanes-edit.sh lane-start lane-end link-estates repos.tsv) +# ------------------------------------------ THE RECEIPT OF WHAT IT PLACED +# +# opensoft/openRepoTools#57, and it descends from COPILOT'S ROUND-3 FINDING ON +# #45 (`openRepoTools:263`) — which is a finding about the retirement directly +# below this section: +# +# "This ownership test is only a searchable content substring, not proof +# that this installer created the file. A user-owned `restart` script +# copied from an old installation or containing the same banner will +# satisfy `grep` and be deleted on the next install […] Use durable +# ownership metadata or another check that distinguishes installer output +# from a user file before removing it." +# +# IT WAS DECLINED FOR `restart`, AND THE REASON DOES NOT GENERALISE. No +# installer that ever placed `restart` wrote a receipt, so no receipt can +# identify the copies that are on people's PATHs today: for that population the +# header marker is the only evidence that exists, and it stays exactly where it +# is. What #57 asks is that EVERY RETIREMENT AFTER THIS ONE have better — which +# costs nothing except that this run write down what it did. +# +# SO IT WRITES DOWN WHAT IT PLACED, one row per regular file: +# +# +# +# and the name is the word this command PRINTED beside that placement — `park`, +# `handoff`, `/ctx` — because one name is a skill AND a command file at four +# different paths, and a row's subject is therefore its DESTINATION — spelled as +# its CANONICAL ABSOLUTE PATH (`receipt_dest`), so that a relative +# `$OPENREPOTOOLS_BIN_DIR` means the same file to the run that wrote the row and +# to every later run from any other directory. A run +# REPLACES the row of every destination it placed and KEEPS every other row, so +# a person who moves `$OPENREPOTOOLS_BIN_DIR` keeps the evidence for the copies +# in the old one, which are the copies a later retirement will meet there. +# +# THE ROWS ARE READ OFF DISK, FROM THE SAME LISTS THE PLACEMENTS ARE MADE FROM, +# after the last of them. Nothing is remembered from inside the loops: the +# planners have already refused every target that is not a regular file this +# user can write, the loops have written them, and what a receipt must carry is +# the digest of the bytes that ARE THERE. That is also why this is one call in +# `install_commands` and not a line inside each loop — the smallest hunk in the +# two functions this estate's other branches are editing. +# +# IT IS NOT AN ARTIFACT OF THE COUNT. The count is the invariant of what +# `--install` PLACES; this is the record of that placement. A ROW GOES TO EVERY +# PLACED REGULAR FILE — the `INSTALLABLES` plus the skill files and the command +# files at both of their destinations — and to nothing else: the two hook +# entries get no row, because an entry inside somebody else's JSON file is not a +# file this command placed, and the receipt carries no row for itself. +# +# THE NUMBER THAT COMES OUT IS DERIVED, NOT STATED, because every statement of +# it went stale the next time a list moved (Copilot on #103, `openRepoTools:325`, +# which found twenty-six where there were twenty-seven): `ARTIFACTS - HOOK_ENTRIES` +# in `tests/test_openrepotools_command.py`, which is `len(INSTALLED) + 2 * +# len(SKILL_NAMES) + 2 * len(COMMAND_NAMES)`. Today that is 27 artifacts, 25 of +# them files, 25 rows — 13 + 6 + 6, since Amendment 16 put `lane-rename` in +# `INSTALLABLES` — and it moves with `INSTALLABLES`, `SKILLS` and `COMMANDS`, +# and with nothing else. +# +# A RECEIPT THAT CANNOT BE WRITTEN IS A NOTE AND NEVER A REFUSAL (#57's own +# words, and the same rule the retirement follows one function down). The +# commands, the skills and the hook entries are what a person asked for; a +# record of them this installer could not write is its problem and not theirs, +# and the marker is still there for a retirement to fall back to. Fail closed, +# out loud, in one line, and carry on. +# +# AND THE RECEIPT IS ONLY EVER ASKED ABOUT A PATH THIS COMMAND COMPUTED. It is +# never read as a list of paths to remove: `retire_commands` walks `RETIRED` +# against its own `$dir` and asks the receipt one question about the target it +# already has in hand — is the file at THIS path the one I wrote. So a receipt +# somebody else authored can change an answer and can never name a file for +# removal, which is the difference between a record and an instruction. + +#: WHERE THE RECEIPT LIVES. `$OPENREPOTOOLS_DATA_DIR`, else the XDG data +#: directory — `$XDG_DATA_HOME`, else `~/.local/share`. It is DATA and does not +#: belong beside the commands in `$OPENREPOTOOLS_BIN_DIR`: a bin directory is a +#: PATH entry, and a `.tsv` in one is a file every shell walks past for ever +#: (`repos.tsv` is there under protest, and Amendment 9(b) says so). XDG is the +#: one convention that answers "where does a tool keep its own record" on Linux +#: and macOS alike, and the override is there because the bin directory has one. +#: +#: THE DIRECTORY AS CONFIGURED IS ONE THING AND THE PATH THIS FILE READS AND +#: WRITES IS ANOTHER (Copilot round 4 on #103, `openRepoTools:365`). The first +#: shape handed the configured string to every caller, so a relative +#: `$OPENREPOTOOLS_DATA_DIR` was `data/installed.tsv` in every line and every +#: `[ -f ]`: a spelling, not a file. `receipt_file` is the CANONICAL path — the +#: directory resolved by `receipt_dest`, the same `pwd -P` the destinations in +#: the rows go through — whenever the directory is THERE TO RESOLVE, and the +#: configured spelling otherwise: a directory that does not exist has no +#: receipt in it to read, and the one caller that MAKES it (`receipt_replace`) +#: creates it first and asks again. A getter that created directories would +#: make a retirement's read a write. +#: +#: WHAT RESOLVING DOES NOT DO is make a relative `data` mean the same directory +#: from two working directories: that is what being relative IS. So +#: `receipt_record` says so, on the line beside the one that names the +#: receipt, rather than leaving a person to find out from a later run that +#: could not see the first one's evidence. +receipt_data_dir() { # the directory as configured, which may be relative + printf '%s\n' "${OPENREPOTOOLS_DATA_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/openRepoTools}" +} +receipt_file() { + receipt_dest "$(receipt_data_dir)/installed.tsv" +} + +#: A TAB AND A NEWLINE AS VALUES, because the receipt is a TSV and a +#: destination carrying either would split one row into two columns or into two +#: rows. `$'\t'` is the shorter spelling and no file in this repository uses +#: one; `printf` is the spelling every one of them does use. The `X` is there +#: because `$( )` strips TRAILING newlines and there would otherwise be nothing +#: left to assign. +RECEIPT_TAB="$(printf '\t')" +RECEIPT_NEWLINE="$(printf '\nX')" +RECEIPT_NEWLINE="${RECEIPT_NEWLINE%X}" + +RECEIPT_ROWS="" # this run's rows, staged in $WORKDIR +RECEIPT_DESTS="" # their destinations, one per line, for the merge +RECEIPT_STAMP="" # the UTC of this run +RECEIPT_COUNT=0 # how many rows it wrote +RECEIPT_WHY="" # why it could not, where it could not + +# THE DIGEST, IN WHICHEVER SPELLING THIS MACHINE HAS. `sha256sum` is GNU and a +# stock macOS ships none; `shasum -a 256` is in the Perl toolchain macOS does +# ship, and is on every Linux this estate runs. Neither is a hard dependency — +# a machine with neither gets the note and the marker — so this is the one +# place either name is spelled. +# +# THE FILE IS FED ON STDIN AND NEVER NAMED AS AN OPERAND, and that is not a +# style choice: GNU `sha256sum` ESCAPES a filename containing a backslash or a +# newline and prefixes the whole line with `\`, so `${out%% *}` off a named +# operand hands back `\` for a bin directory somebody spelled with a +# backslash in it. Read from stdin, both spellings print ` -`. +sha256_of() { # — its digest, or 1 and nothing at all + local out="" + [ -f "$1" ] && [ -r "$1" ] || return 1 + if command -v sha256sum >/dev/null 2>&1; then + out="$(sha256sum <"$1" 2>/dev/null)" || return 1 + elif command -v shasum >/dev/null 2>&1; then + out="$(shasum -a 256 <"$1" 2>/dev/null)" || return 1 + else + return 1 + fi + out="${out%% *}" + [ -n "$out" ] || return 1 + printf '%s\n' "$out" +} + +# THE ONE LINE A RECEIPT THAT COULD NOT BE WRITTEN GETS. It says where, why, +# that the install is not affected, and what a retirement falls back to — and +# it is `say` and not `die`, which is the whole of #57's ruling on this. +# +# THE EXIT BELONGS TO THE REASON AND NOT TO THE LINE, which is why it is a +# sentence a caller puts in its `why` rather than a tail on every note: a +# machine with no `sha256sum` and no `shasum` is not a machine that wants to be +# told to name a different directory. +RECEIPT_ELSEWHERE='name a directory this installer may write with $OPENREPOTOOLS_DATA_DIR' +receipt_note() { # + say "receipt: NOT written to $1 — $2. Everything above is installed; a later retirement falls back to the \`Installed on PATH by\` header, which is the evidence it has today." +} + +# THE DESTINATION AS THE RECEIPT SPELLS IT: ABSOLUTE, AND WITH EVERY SYMLINK IN +# ITS DIRECTORY RESOLVED (Copilot on #103, `openRepoTools:524`). A row is +# evidence about ONE FILE, and `$OPENREPOTOOLS_BIN_DIR=bin` — which the planner +# accepts — names a different file from every directory it is run in: the first +# shape stored `$dir/$name` as supplied, so a row written from one working +# directory was compared, as a string, against the `bin/restart` of another, and +# that other file was hashed and could be removed. So the one spelling every +# read and write goes through is the PHYSICAL path of the file's directory plus +# its own name — `record`, `verdict` and `forget` all ask THIS, never the +# `$dir/$name` they were handed, and the line a person reads still prints the +# path as they spelled it. +# +# THE DIRECTORY IS RESOLVED, NEVER THE FILE: `receipt_add` has already refused a +# symlink at the path itself, and a link is somebody's decision about their own +# PATH, which is no business of a row's. `(cd -- "$dir" && pwd -P)` is the +# portable spelling (macOS and bash 3.2 have no `realpath`, and `readlink -f` +# is not in a stock macOS userland — `lanes-edit.sh` asks the same question the +# same way). `CDPATH` is emptied first, because a `cd` through one PRINTS the +# directory it went to and the substitution would hold two lines. And the +# trailing `X` is the one `RECEIPT_NEWLINE` uses: `$( )` strips trailing +# newlines, and a directory whose name ENDS in one would otherwise resolve to a +# DIFFERENT, shorter path — one with no newline in it, past the TSV guard. +# +# IT NEVER FAILS: a directory that cannot be entered answers with the path as +# given, which is not absolute, and the one caller that WRITES a row refuses a +# destination that is not — the callers that only READ simply never match it. +receipt_dest() { # — its canonical absolute spelling, else the path as given + local dir="" base="" real="" + case "$1" in + */*) dir="${1%/*}"; base="${1##*/}" ;; + *) dir="."; base="$1" ;; + esac + [ -n "$dir" ] || dir="/" + real="$(CDPATH= cd -- "$dir" >/dev/null 2>&1 && pwd -P && printf X)" || + { printf '%s\n' "$1"; return 0; } + real="${real%X}" + real="${real%"$RECEIPT_NEWLINE"}" + case "$real" in + /) printf '/%s\n' "$base" ;; + *) printf '%s/%s\n' "$real" "$base" ;; + esac +} + +# WHETHER A PATH CANNOT BE A COLUMN OF THE RECEIPT: it carries a tab or a newline. +receipt_tsv_unsafe() { # — 0 if it cannot, 1 if it can + case "$1" in + *"$RECEIPT_TAB"*|*"$RECEIPT_NEWLINE"*) return 0 ;; + esac + return 1 +} + +# ONE ROW, STAGED. The digest is of the bytes at the destination, so a run that +# placed nothing new still records what is there — which is the point: the +# receipt answers "is the file at this path mine", not "did I write it today". +# +# A DESTINATION CARRYING A TAB OR A NEWLINE GETS NO RECEIPT AT ALL, rather than +# a row that is silently two columns or two rows. `$OPENREPOTOOLS_BIN_DIR` is a +# path a person chooses — the retirement one function down already prints its +# `rm` quoted because of the spaces people put in them — and a TSV has no +# escape. Failing the whole receipt rather than dropping the row is the fail- +# closed half: a receipt missing exactly the file a retirement will ask about, +# with nothing on screen, is worse than no receipt at all. +receipt_add() { # — 0, or 1 with $RECEIPT_WHY set + local name="$1" given="$2" dest="" digest="" + [ -f "$given" ] && [ ! -L "$given" ] || return 0 + # THE TAB AND NEWLINE QUESTION IS ASKED OF BOTH SPELLINGS — the one the person + # supplied, BEFORE anything resolves it, and the canonical one, which a symlink + # in the directory can make carry a tab the supplied one never had. + if receipt_tsv_unsafe "$given"; then + RECEIPT_WHY="$given has a tab or a newline in it and this file is tab-separated" + return 1 + fi + dest="$(receipt_dest "$given")" + if receipt_tsv_unsafe "$dest"; then + RECEIPT_WHY="$dest has a tab or a newline in it and this file is tab-separated" + return 1 + fi + case "$dest" in + /*) ;; + *) + RECEIPT_WHY="$given could not be resolved to an absolute path" + return 1 + ;; + esac + digest="$(sha256_of "$dest")" || { + RECEIPT_WHY="$dest could not be digested" + return 1 + } + printf '%s\t%s\t%s\t%s\n' "$name" "$dest" "$digest" "$RECEIPT_STAMP" \ + >>"$RECEIPT_ROWS" || { + RECEIPT_WHY="the rows could not be staged in $WORKDIR" + return 1 + } + printf '%s\n' "$dest" >>"$RECEIPT_DESTS" || { + RECEIPT_WHY="the rows could not be staged in $WORKDIR" + return 1 + } + RECEIPT_COUNT=$((RECEIPT_COUNT + 1)) + return 0 +} + +# EVERY ROW OF THE RECEIPT WHOSE DESTINATION IS NOT ONE OF THESE, appended to a +# staged file. ONE implementation for the two callers that need it — the merge, +# which keeps what this run did not replace, and the retirement, which drops +# the row of the file it removed — because two copies of one parse is how the +# two come to disagree about what a row is. +# +# A ROW IS FOUR FIELDS OR MORE, and anything else in that file is ignored +# rather than repaired: a comment somebody added, a truncated line, a column a +# later version appends. Reading "at least four" rather than "exactly four" is +# what lets a fifth column arrive without this half having to be taught about +# it first. +# +# AND A ROW WHOSE FILE IS NO LONGER AT THAT PATH IS NOT KEPT (Copilot round 1 +# on #103). `receipt_forget` runs AFTER the `rm` it accompanies, so a write +# that fails in the instant between them leaves the receipt naming a path this +# installer's copy has left — and nothing would ever clear it. This makes that +# window CLOSE BY ITSELF: the next `--install` reads the same file, finds that +# file gone, and drops the row. It is also what stops the receipt growing for +# ever with the bin directories and profile roots people delete. +# +# WHAT IS KEPT IS WHAT COULD HAVE BEEN WRITTEN: a REGULAR FILE THAT IS NOT A +# LINK, which is `receipt_add`'s own test one function up, spelled the same way +# (Copilot round 2 on #103, `openRepoTools:468`). The first shape of this asked +# `[ -e ] || [ -L ]` — "is there anything at all at that path" — which kept the +# row of a destination that had become a directory, a socket, a FIFO or a +# symlink. A row is evidence about a REGULAR FILE this installer placed, and +# where one of those sits at that path the file it is evidence about is not +# there; the two halves now say that in one sentence each rather than two +# different ones. +receipt_rows_except() { # + local row="" rest="" dest="" + [ -f "$1" ] && [ ! -L "$1" ] || return 0 + # A RECEIPT THAT EXISTS AND CANNOT BE OPENED IS A FAILURE, NOT AN EMPTY ONE + # (Copilot round 4 on #103, `openRepoTools:575`). The `done <"$1"` below + # fails quietly when it cannot open the file, and the `return 0` this + # function ends in then reported a clean scan of no rows — so + # `receipt_record` replaced the receipt with this run's rows alone and every + # row about a copy this run did not place was gone. OPENED, not `-r`: the + # answer that matters is the one the loop is about to get, and for root those + # two agree anyway. Both callers already print their note on a non-zero. + : 2>/dev/null <"$1" || return 1 + while IFS= read -r row || [ -n "$row" ]; do + case "$row" in + *"$RECEIPT_TAB"*"$RECEIPT_TAB"*"$RECEIPT_TAB"*) ;; + *) continue ;; + esac + rest="${row#*"$RECEIPT_TAB"}" + dest="${rest%%"$RECEIPT_TAB"*}" + # A RELATIVE DESTINATION IS NEVER KEPT (Copilot on #103, `openRepoTools:524`). + # This command writes none any more, and a row that says `bin/park` is a + # row about whichever directory the next run is standing in: the `-f` just + # below would answer for THAT run's `bin/park`, which is the cwd-dependence + # `receipt_dest` removes from every other read. + case "$dest" in + /*) ;; + *) continue ;; + esac + if grep -qFx -e "$dest" -- "$2"; then continue; fi + [ -f "$dest" ] && [ ! -L "$dest" ] || continue + printf '%s\n' "$row" >>"$3" || return 1 + done <"$1" + return 0 +} + +# THE WRITE ITSELF: `mktemp` in the same directory, `mv -f`. It is the shape +# `place_skill_and_hook` writes `settings.json` with and for the same reason — +# a half-written receipt is a receipt that says a file is not this installer's. +# AND NO `chmod`: the receipt's mode is the 0600 `mktemp` gives the temporary, +# whatever the umask, which is `settings.json`'s mode too. Two rules meet here +# and this is how both are kept. #48 (PR #102) rules that EVERY `chmod` this +# command performs fails through `die` — a mode-stamp failure is a refusal — +# and `tests/test_openrepotools_command.py` reads the script's text to hold +# every site to it; #57 rules that a receipt this command cannot write is a +# NOTE and never a refused install. A stamp that may not `die` and may not +# fall silent is a stamp that must not exist, and 0600 wants none: this is one +# account's evidence, read by that account's own retirements, and nothing else +# reads it. +# +# `mv -f` DOES NOT FOLLOW A SYMLINK — it replaces the link itself — which is +# why `receipt_record` refuses one BEFORE reaching this: silently swapping a +# person's link for a regular file is the same act `cp` through one is, in the +# other direction. +receipt_replace() { # — 0, or 1 having changed nothing + local file="" dir="" tmp="" + file="$(receipt_file)" + dir="$(dirname -- "$file")" + mkdir -p -- "$dir" 2>/dev/null || return 1 + # THE DIRECTORY EXISTS NOW, so the canonical path can be had (round 4 on + # #103, `openRepoTools:365`): on a first install the answer above was still + # the configured spelling. + file="$(receipt_file)" + dir="$(dirname -- "$file")" + tmp="$(mktemp -- "$dir/installed.tsv.XXXXXX" 2>/dev/null)" || return 1 + cp -- "$1" "$tmp" 2>/dev/null || { rm -f -- "$tmp"; return 1; } + mv -f -- "$tmp" "$file" 2>/dev/null || { rm -f -- "$tmp"; return 1; } + return 0 +} + +# THE RECEIPT FOR THIS RUN, from the same three lists the placements are made +# from. Called once, after the last placement and before the one removal. +receipt_record() { + local dir="${OPENREPOTOOLS_BIN_DIR:-$HOME/.local/bin}" + local file="" kind="" name="" target="" why="" + file="$(receipt_file)" + # WHAT IS AT THAT PATH, BEFORE A BYTE OF IT IS READ OR WRITTEN — the same + # question `plan_install_targets` and `plan_skill_targets` put to every + # path this command writes, asked through the same `path_kind`. A symlink, + # a directory or anything else there is not this installer's receipt: it is + # neither read nor replaced, and the note says which it found. + if kind="$(path_kind "$file")"; then + receipt_note "$file" "it is $kind, and this installer's receipt is a regular file it makes itself — $RECEIPT_ELSEWHERE" + return 0 + fi + # AND WHETHER THERE IS ANYTHING HERE TO DIGEST WITH, asked once and named as + # the cause it is. Without this the first file would fail `sha256_of` and + # the note would say that FILE could not be digested — true, and not the + # sentence a person needs to read on a machine that has neither tool. + if ! command -v sha256sum >/dev/null 2>&1 && ! command -v shasum >/dev/null 2>&1; then + receipt_note "$file" "there is no \`sha256sum\` and no \`shasum -a 256\` on this PATH, and a row without a digest is a row that proves nothing" + return 0 + fi + workdir + RECEIPT_ROWS="$WORKDIR/receipt-rows" + RECEIPT_DESTS="$WORKDIR/receipt-dests" + RECEIPT_COUNT=0 + RECEIPT_WHY="" + RECEIPT_STAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + : >"$RECEIPT_ROWS" 2>/dev/null || { receipt_note "$file" "its rows could not be staged in $WORKDIR"; return 0; } + : >"$RECEIPT_DESTS" 2>/dev/null || { receipt_note "$file" "its rows could not be staged in $WORKDIR"; return 0; } + for name in "${INSTALLABLES[@]}"; do + receipt_add "$name" "$dir/$name" || { receipt_note "$file" "$RECEIPT_WHY"; return 0; } + done + # THE SKILLS AND THE COMMAND FILES TOO, under the name each was PRINTED + # with: they are regular files this run placed, at paths it chose, and a + # record of what was placed that left them out — every one a path a + # retirement could one day meet — would answer only part of the question + # anybody asks it. + for name in "${SKILLS[@]}"; do + for target in "$(skills_home)/shared/skills/$name/SKILL.md" "$(claude_home)/skills/$name/SKILL.md"; do + receipt_add "$name" "$target" || { receipt_note "$file" "$RECEIPT_WHY"; return 0; } + done + done + for name in "${COMMANDS[@]}"; do + for target in "$(skills_home)/shared/commands/$name.md" "$(claude_home)/commands/$name.md"; do + receipt_add "/$name" "$target" || { receipt_note "$file" "$RECEIPT_WHY"; return 0; } + done + done + # THIS RUN'S ROWS FIRST, THEN EVERY EARLIER ROW IT DID NOT REPLACE. + receipt_rows_except "$file" "$RECEIPT_DESTS" "$RECEIPT_ROWS" || + { receipt_note "$file" "the rows already there could not be read"; return 0; } + if receipt_replace "$RECEIPT_ROWS"; then + file="$(receipt_file)" + say "receipt: $RECEIPT_COUNT placed files recorded in $file" + case "$(receipt_data_dir)" in + /*) ;; + *) + say " the data directory $(receipt_data_dir) is a RELATIVE path, which names a different directory from every working directory — this run resolved it to $(dirname -- "$file"). Name an absolute one with \$OPENREPOTOOLS_DATA_DIR so a later \`--install\` from anywhere reads this receipt." + ;; + esac + else + why="$(unusable_dir_kind "$(dirname -- "$file")")" || why="the write failed" + receipt_note "$file" "$why — $RECEIPT_ELSEWHERE" + fi + return 0 +} + +# WHAT THE RECEIPT SAYS ABOUT ONE PATH, in one word, and never an exit code: a +# `$( )` that returned non-zero under `set -e` would take the install with it, +# and "I have nothing to say about this file" is an ANSWER here rather than a +# failure. +# +# placed — a row names this path and the bytes there still have its digest. +# This installer's copy beyond argument. +# edited — a row names it and the digest has MOVED. Somebody's edit of an +# installed command, or their own file at a path one used to be: +# either way not this installer's to remove. +# unknown — no receipt, or no row for THIS path. The header marker is what +# answers then, exactly as it does today. +# unverified — a row names it and NOTHING COULD BE DIGESTED to compare with: +# no `sha256sum` and no `shasum` on this PATH, a tool that failed, or +# a file this user cannot read (Copilot on #103, `openRepoTools:711`). +# It is NOT `unknown`, and the difference is the whole of #57: the +# header marker is the answer for a path the receipt has NOTHING TO +# SAY about, and a path it HAS a row for and cannot check is a path +# it has something to say about and cannot say yet. The first shape +# of this folded the two into one word, so the marker removed an +# edited, receipt-tracked command on exactly the machine that could +# not tell it was edited — the ownership mistake the receipt exists +# to prevent, made by the code that carries it. Named and left. +# unreadable — the receipt EXISTS and cannot be OPENED (Copilot round 4 on +# #103, `openRepoTools:716`). The scan never ran, so "no row" is not +# something it found: it is the same ignorance as `unverified`, one +# step earlier, and it gets the same answer — named and left — +# with its own sentence, because the person's remedy is the +# receipt's mode and not a digest tool. `unknown` is for a scan that +# RAN and found nothing. +# +# THE FIRST MATCHING ROW WINS. This command writes one row per destination, so +# a second is a hand-edit, and picking the first is the only rule that does not +# depend on which end of the file somebody appended to. +receipt_verdict() { # — placed | edited | unverified | unreadable | unknown, always 0 + local file="" row="" rest="" dest="" digest="" disk="" want="" + file="$(receipt_file)" + [ -f "$file" ] && [ ! -L "$file" ] || { printf 'unknown\n'; return 0; } + : 2>/dev/null <"$file" || { printf 'unreadable\n'; return 0; } + # THE ROW IS ABOUT A FILE, AND THE FILE IS ASKED ABOUT BY THE SAME SPELLING + # THE ROW WAS WRITTEN IN (`receipt_dest`, Copilot on #103, `openRepoTools:524`). + want="$(receipt_dest "$1")" + while IFS= read -r row || [ -n "$row" ]; do + case "$row" in + *"$RECEIPT_TAB"*"$RECEIPT_TAB"*"$RECEIPT_TAB"*) ;; + *) continue ;; + esac + rest="${row#*"$RECEIPT_TAB"}" + dest="${rest%%"$RECEIPT_TAB"*}" + [ "$dest" = "$want" ] || continue + digest="${rest#*"$RECEIPT_TAB"}" + digest="${digest%%"$RECEIPT_TAB"*}" + disk="$(sha256_of "$1")" || { printf 'unverified\n'; return 0; } + if [ "$disk" = "$digest" ]; then printf 'placed\n'; else printf 'edited\n'; fi + return 0 + done <"$file" + printf 'unknown\n' + return 0 +} + +# AND THE ROW OF A FILE THAT IS NO LONGER THERE IS DROPPED, because a receipt +# that still names a removed path would answer `edited` about whatever somebody +# puts there next — which is the safe direction, and still a sentence about +# their file that is not true. +# +# NOT ONE OF ITS FAILURES IS SILENT (Copilot round 1 on #103, `openRepoTools:660`). +# Three of the four steps here used to `return 0` on a write that failed — a +# full disk while staging, a data directory that went away between the read and +# the write — so the receipt could keep a live row for a path the `rm` above had +# just emptied and NOTHING on screen would say so. Every one of them now reaches +# the same note, and the note names the repair: `receipt_rows_except` drops a +# row whose file is gone, so the next `--install` clears it whatever happened +# here. Still never a `die`: the retirement has already done its work. +receipt_forget() { # + local file="" kept="" dests="" dropped=1 + file="$(receipt_file)" + [ -f "$file" ] && [ ! -L "$file" ] || return 0 + workdir + kept="$WORKDIR/receipt-kept" + dests="$WORKDIR/receipt-forget" + # THE ROW IS DROPPED BY THE SPELLING IT WAS WRITTEN IN (`receipt_dest`); the + # note below still names the path as the person spelled it. + if : >"$kept" 2>/dev/null && printf '%s\n' "$(receipt_dest "$1")" >"$dests" 2>/dev/null && + receipt_rows_except "$file" "$dests" "$kept" && + receipt_replace "$kept"; then + dropped=0 + fi + [ "$dropped" = 0 ] || + receipt_note "$file" "its row for $1 could not be dropped, so it still names a path this installer's copy has left — the next \`--install\` drops it, because a row whose file is gone is not kept" + return 0 +} + # ---------------------------------------------- THE WORDS THAT WERE RETIRED # # A WORD THAT LEAVES THE LIST HAS TO LEAVE THE DIRECTORY (Copilot round 1 on @@ -289,7 +820,7 @@ INSTALLABLES=(openRepoTools park resume status lane lanes lane-handoff lane-rena RETIRED=(restart) retire_commands() { local dir="${OPENREPOTOOLS_BIN_DIR:-$HOME/.local/bin}" - local name target + local name target verdict="" for name in "${RETIRED[@]}"; do target="$dir/$name" # `-e` FOLLOWS THE LINK AND IS FALSE FOR A DANGLING ONE (Copilot round 4 on @@ -308,7 +839,41 @@ retire_commands() { # (`unplaceable_kind`, which names the link rather than writing through # it). A link is somebody's decision about their own PATH; it is named # here and left, like every other file this installer did not write. - if [ ! -L "$target" ] && [ -f "$target" ] && grep -qF -e 'Installed on PATH by `openRepoTools --install`' -- "$target" 2>/dev/null; then + # + # THE RECEIPT IS ASKED FIRST, AND THE MARKER IS WHAT ANSWERS WHEN IT HAS + # NOTHING TO SAY (#57, on Copilot's round-3 finding on #45). A row whose + # digest still matches is this installer's copy beyond argument; a row + # whose digest has MOVED is a file this installer may not remove even + # though the header is still in it, because what the header proves is + # where those lines came from and not who owns the bytes now. It is + # asked only of a REGULAR FILE THAT IS NOT A LINK, for the reason the + # `! -L` below gives: a link is somebody's decision about their own + # PATH, whatever the receipt remembers about the path it sits at. + verdict=unknown + if [ ! -L "$target" ] && [ -f "$target" ]; then + verdict="$(receipt_verdict "$target")" + fi + if [ "$verdict" = placed ]; then + rm -f -- "$target" + # AND THE ROW GOES WITH THE FILE, so the receipt never names a path + # nothing of this installer's is at any more. + receipt_forget "$target" + say "$name: RETIRED, removed from $target — this installer's own receipt carries its digest (lane-collision-protocol Amendment 18 Addendum 2; \`lane \` is that act now)" + elif [ "$verdict" = edited ]; then + say "$name: RETIRED by lane-collision-protocol Amendment 18 Addendum 2, and $target no longer holds the bytes this installer's receipt recorded — an edit of it, or your own file at that path. Left exactly as it is; remove it yourself if it is stale: rm -f -- \"$target\"" + elif [ "$verdict" = unverified ]; then + # A ROW IT CANNOT CHECK IS NEVER THE HEADER'S TO ANSWER (Copilot on #103, + # `openRepoTools:711`): the receipt names this path, so the marker is not + # the evidence — and the digest that would have settled it could not be + # taken. One line, the same shape as the two above: where, why, the `rm`. + say "$name: RETIRED by lane-collision-protocol Amendment 18 Addendum 2, and $target has a row in this installer's receipt that could not be checked, because no digest tool answered (\`sha256sum\`, \`shasum -a 256\`) — so it is NOT removed on the strength of its header either. Left exactly as it is; remove it yourself if it is stale: rm -f -- \"$target\"" + elif [ "$verdict" = unreadable ]; then + # A RECEIPT THAT CANNOT BE OPENED CANNOT SAY "NO ROW" (Copilot round 4 on + # #103, `openRepoTools:716`): it may carry exactly the row that says this + # file is an edit. Same shape as the arm above, and it names the receipt, + # because that is the file whose mode the person has to look at. + say "$name: RETIRED by lane-collision-protocol Amendment 18 Addendum 2, and this installer's receipt $(receipt_file) exists but could not be read, so whether $target is its copy is not known — it is NOT removed on the strength of its header either. Left exactly as it is; remove it yourself if it is stale: rm -f -- \"$target\"" + elif [ ! -L "$target" ] && [ -f "$target" ] && grep -qF -e 'Installed on PATH by `openRepoTools --install`' -- "$target" 2>/dev/null; then rm -f -- "$target" say "$name: RETIRED, removed from $target (lane-collision-protocol Amendment 18 Addendum 2; \`lane \` is that act now)" else @@ -463,6 +1028,14 @@ install_commands() { # Every other refusal in this file places NOTHING rather than half of it; this # one removes nothing until the rest of the install has actually happened. place_skill_and_hook + # THE RECEIPT IS WRITTEN BETWEEN THE LAST PLACEMENT AND THE ONE REMOVAL + # (#57). After the placements, because a row carries the digest of the bytes + # that are actually at a destination and every destination has to have been + # written first; before the retirement, because that reads it and then drops + # the row of anything it removes. A run that REFUSED part way through the + # placements writes none of it and leaves the header marker as the evidence, + # which is the population the marker exists for anyway. + receipt_record retire_commands # ONE PATH NOTE for the four of them: the directory is the same one. case ":${PATH:-}:" in diff --git a/tests/test_openrepotools_command.py b/tests/test_openrepotools_command.py index 1908631..1ecaf11 100644 --- a/tests/test_openrepotools_command.py +++ b/tests/test_openrepotools_command.py @@ -24,12 +24,14 @@ from __future__ import annotations +import hashlib import os import re import shutil import stat import subprocess +from datetime import datetime from pathlib import Path import pytest @@ -135,6 +137,15 @@ reason="`--install` merges two hook entries with jq (Amendment 9(b), Amendment 12 act 3)") +#: A MODE-000 FILE IS ONLY UNREADABLE TO SOMEBODY WHO IS NOT ROOT. The same +#: idiom `tests/test_install_skill_and_hook.py` carries for the destinations it +#: makes unwritable: a root `--install` opens whatever it likes, so the cases +#: that need a receipt it CANNOT open are skipped there, not made to pass. +NOT_ROOT = pytest.mark.skipif( + hasattr(os, "geteuid") and os.geteuid() == 0, + reason="root can read a file whose mode says otherwise") + + def command_env(home: Path | None = None, env: dict | None = None) -> dict: """The environment this suite controls, for a run of the command. @@ -149,10 +160,22 @@ def command_env(home: Path | None = None, env: dict | None = None) -> dict: variable is inherited from the developer's own shell. A suite that installed a skill into a person's live profile set would be a suite nobody could run twice. + + AND `$XDG_DATA_HOME` IS CLEARED FOR THAT SAME REASON (#57). Since + `--install` writes its receipt to + `${OPENREPOTOOLS_DATA_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/openRepoTools}`, + a developer who exports `$XDG_DATA_HOME` — and on a Linux desktop that is + a normal thing to have exported — would have this suite writing into their + real data directory however carefully `$HOME` was redirected. Cleared, the + default applies, the default hangs off `$HOME`, and every byte lands in + `tmp_path`. `$OPENREPOTOOLS_DATA_DIR` is cleared with the rest of the + `$OPENREPOTOOLS_*` family, so a developer's own shell cannot move what + these tests assert about. """ environ = dict(os.environ) for name in ("OPENREPOTOOLS_REF", "OPENREPOTOOLS_REPO", - "OPENREPOTOOLS_BIN_DIR", "CLAUDE_PROFILES_HOME", + "OPENREPOTOOLS_BIN_DIR", "OPENREPOTOOLS_DATA_DIR", + "XDG_DATA_HOME", "CLAUDE_PROFILES_HOME", "CLAUDE_USER_DIR", "AGENT_PROTOCOL_ROOT", "PROJECTS_DIR"): environ.pop(name, None) if home is not None: @@ -165,15 +188,20 @@ def command_env(home: Path | None = None, env: dict | None = None) -> dict: def run_cmd(*args: str, home: Path | None = None, - env: dict | None = None) -> subprocess.CompletedProcess: + env: dict | None = None, + cwd: Path | None = None) -> subprocess.CompletedProcess: """The command, run from its file, with that environment. `input=""` means stdin is a pipe rather than a terminal, which is what every run in this file wants: nothing here may ask a question. + + `cwd` is for the tests that name a RELATIVE path — a `$OPENREPOTOOLS_BIN_DIR` + spelled `bin` means a different directory from every working directory it + is run in, and that is exactly what they are about (#57, Copilot on #103). """ return subprocess.run(["bash", str(COMMAND), *args], capture_output=True, text=True, check=False, input="", - env=command_env(home, env)) + env=command_env(home, env), cwd=cwd) # --- what it says about itself --------------------------------------------- @@ -236,7 +264,8 @@ def test_help_names_every_variable_it_reads(): the usage does not name is a variable nobody finds.""" result = run_cmd("--help") for name in ("$OPENREPOTOOLS_REPO", "$OPENREPOTOOLS_REF", - "$OPENREPOTOOLS_BIN_DIR", "$AGENT_PROTOCOL_ROOT", + "$OPENREPOTOOLS_BIN_DIR", "$OPENREPOTOOLS_DATA_DIR", + "$XDG_DATA_HOME", "$AGENT_PROTOCOL_ROOT", "$CLAUDE_PROFILES_HOME", "$PROJECTS_DIR"): assert name in result.stdout, name @@ -751,6 +780,842 @@ def test_the_retirement_prints_a_removal_that_can_be_pasted(tmp_path, name): assert f"rm {mine}\n" not in result.stdout +# --- the receipt of what it placed ------------------------------------------ + +#: THE RECEIPT'S PATH UNDER A REDIRECTED `$HOME`. `command_env` clears +#: `$XDG_DATA_HOME` as well as `$OPENREPOTOOLS_DATA_DIR`, so what these tests +#: exercise is the DEFAULT — `~/.local/share/openRepoTools/installed.tsv` — and +#: it lands inside `tmp_path` because `$HOME` does. +def receipt_path(home: Path) -> Path: + return home / ".local" / "share" / "openRepoTools" / "installed.tsv" + + +def receipt_rows(home: Path) -> list: + """The receipt as a list of `(name, destination, sha256, utc)` tuples.""" + return [tuple(line.split("\t")) + for line in receipt_path(home).read_text( + encoding="utf-8").splitlines() if line] + + +def receipt_rows_at(receipt: Path) -> list: + """`receipt_rows`, for a receipt that is not at the default path.""" + return [tuple(line.split("\t")) + for line in receipt.read_text(encoding="utf-8").splitlines() + if line] + + +def placed_files(home: Path) -> dict: + """Every REGULAR FILE an `--install` into `home` places, by destination. + + Derived from the same three lists the artifact count is, and it comes to + `ARTIFACTS - HOOK_ENTRIES`: the two hook entries are entries inside + somebody else's JSON file rather than files this command placed, so they + get no row. The NAME beside each is the word `--install` prints on the line + that places it — `park`, `handoff`, `/ctx` — because one name is a skill + AND a command file at four different paths, which is why the row's subject + is its destination. + """ + files = {str(home / ".local" / "bin" / name): name for name in INSTALLED} + for name in SKILL_NAMES: + for root in (home / ".claude-profiles" / "shared", home / ".claude"): + files[str(root / "skills" / name / "SKILL.md")] = name + for name in COMMAND_NAMES: + for root in (home / ".claude-profiles" / "shared", home / ".claude"): + files[str(root / "commands" / f"{name}.md")] = f"/{name}" + return files + + +@NEEDS_JQ +def test_install_writes_a_receipt_of_every_file_it_placed(tmp_path): + """ONE ROW PER PLACED FILE, WITH THE DIGEST OF THE BYTES THAT ARE THERE + (#57). + + The only evidence `retire_commands` had that a file on a PATH was one this + installer wrote is the `Installed on PATH by ` header — a content + substring, which Copilot's round-3 review of #45 named at + `openRepoTools:263` and which a person's own script can carry by having + been copied from an old installation. A receipt is the evidence that + substring is not: the digest of what this run actually placed, at the path + it placed it. + + ONE ROW PER PLACED REGULAR FILE, which is `ARTIFACTS - HOOK_ENTRIES` and + is derived rather than stated: the `INSTALLED` files plus the skill and + command files at both of their destinations. The two hook entries are + entries inside `~/.claude/settings.json` and not files this command + placed, and the receipt carries no row for itself either. Today that is + 27 artifacts and 25 rows (13 + 6 + 6, since Amendment 16 put `lane-rename` + in the list); the number moves with those three lists and with nothing + else, which is why no assertion below spells it. + """ + result = run_cmd("--install", home=tmp_path) + assert result.returncode == 0, result.stderr + receipt = receipt_path(tmp_path) + assert receipt.is_file(), result.stdout + assert stat.S_IMODE(receipt.stat().st_mode) == 0o600, ( + "the receipt is born at mktemp's 0600 and no chmod touches it: every " + "chmod this command performs must fail through die (#48), and a " + "receipt that cannot be written is a note and never a refusal (#57), " + "so the one mode both rules allow is the one mktemp gives") + rows = receipt_rows(tmp_path) + expected = placed_files(tmp_path) + assert len(rows) == ARTIFACTS - HOOK_ENTRIES == len(expected), ( + f"{len(rows)} rows for {len(expected)} placed files:\n" + receipt.read_text(encoding="utf-8")) + for name, destination, digest, utc in rows: + assert destination in expected, f"a row for a file nothing placed: {destination}" + assert name == expected[destination], destination + assert digest == hashlib.sha256( + Path(destination).read_bytes()).hexdigest(), ( + f"the row for {destination} carries a digest of other bytes") + datetime.strptime(utc, "%Y-%m-%dT%H:%M:%SZ") + assert {row[1] for row in rows} == set(expected) + assert f"receipt: {len(rows)} placed files recorded in {receipt}" \ + in result.stdout, ( + "a file written without a line is a file nobody can ask about:\n" + + result.stdout) + + +def test_the_help_and_the_readme_say_the_mode_the_receipt_is_born_at(): + """THE CONTRACT IS ONE NUMBER IN FOUR PLACES, AND THE TEST ABOVE ONLY HOLDS + ONE OF THEM (Copilot on #103, `openRepoTools:503`). + + The pull request's description said the receipt is 0644 while the code, the + test and the README said 0600 — deliberately: #48's "every `chmod` this + command performs fails through `die`" meets #57's "a receipt it cannot write + is a note", and a stamp that may neither die nor fall silent is a stamp that + must not exist, so `mktemp`'s 0600 stands. A description can be edited and a + usage line cannot be reviewed away, so the two documents a person reads + from the install say it and this reads them: each says 0600 in its receipt + paragraph and neither carries a 0644 there. + """ + help_text = run_cmd("--help").stdout + readme = (REPO / "README.md").read_text(encoding="utf-8") + for where, text, start in (("--help", help_text, "It then writes a RECEIPT"), + ("README.md", readme, "**And it writes down what it placed.**")): + assert start in text, f"{where} no longer has the receipt paragraph" + paragraph = text.split(start, 1)[1].split("\n\n", 1)[0] + assert "mode 0600" in paragraph, ( + f"{where} does not say the mode the receipt is born at:\n{paragraph}") + assert "0644" not in paragraph and "644" not in paragraph, ( + f"{where} says a mode the receipt is not:\n{paragraph}") + + +@pytest.mark.parametrize("name", RETIRED) +@NEEDS_JQ +def test_a_retirement_reads_the_receipt_before_the_header(tmp_path, name): + """AND THE RECEIPT IS STRONGER THAN THE HEADER, WHICH IS THE WHOLE POINT. + + The file here carries NO marker at all — nothing a `grep` could find — and + it is still removed, because a row of this installer's own receipt says + those exact bytes were placed at that exact path. That is ownership + evidence rather than a searchable substring (#57, on Copilot round 3 of + #45 at `openRepoTools:263`). + + And the row goes with the file: a receipt that still named the path would + answer `edited` about whatever somebody puts there next. + """ + bin_dir = tmp_path / ".local" / "bin" + bin_dir.mkdir(parents=True) + stale = bin_dir / name + stale.write_text("#!/usr/bin/env bash\n" + f"# {name}, with no banner of any kind in it\n" + "echo stale\n", encoding="utf-8") + stale.chmod(0o755) + receipt = receipt_path(tmp_path) + receipt.parent.mkdir(parents=True) + receipt.write_text( + f"{name}\t{stale}\t{hashlib.sha256(stale.read_bytes()).hexdigest()}" + "\t2026-09-15T04:04:16Z\n", encoding="utf-8") + + result = run_cmd("--install", home=tmp_path) + assert result.returncode == 0, result.stderr + assert not stale.exists(), ( + f"the receipt names `{name}` and its digest still matches, and it is " + "still on PATH:\n" + result.stdout) + assert f"{name}: RETIRED" in result.stdout, result.stdout + assert "receipt carries its digest" in result.stdout, ( + "the line says which evidence removed it, because the two kinds are " + f"not the same claim:\n{result.stdout}") + assert not [row for row in receipt_rows(tmp_path) if row[1] == str(stale)], ( + "the row of a file that is no longer there was kept:\n" + + receipt.read_text(encoding="utf-8")) + + +@pytest.mark.parametrize("name", RETIRED) +@NEEDS_JQ +def test_a_file_the_receipt_no_longer_recognises_is_named_and_left(tmp_path, name): + """A DIGEST THAT HAS MOVED IS SOMEBODY'S EDIT, AND IT OUTRANKS THE MARKER. + + This file carries the header — `grep` finds it, and today's fallback would + delete it — and the receipt says the bytes at that path are NOT the ones it + placed. So it is a person's edit of an installed command, or their own file + at a path one used to be at, and either way not this installer's to remove: + named, left exactly as it is, with the one line that removes it printed for + them, like every other file this installer did not write. + + Its row is KEPT, because the file is: a receipt drops a row when the file + goes, not when it stops matching. + """ + bin_dir = tmp_path / ".local" / "bin" + bin_dir.mkdir(parents=True) + mine = bin_dir / name + mine.write_text( + "#!/usr/bin/env bash\n" + "# Installed on PATH by `openRepoTools --install`, and then edited.\n" + "echo my own edit\n", encoding="utf-8") + mine.chmod(0o755) + before = mine.read_bytes() + receipt = receipt_path(tmp_path) + receipt.parent.mkdir(parents=True) + receipt.write_text( + f"{name}\t{mine}\t{hashlib.sha256(b'the bytes it placed').hexdigest()}" + "\t2026-09-15T04:04:16Z\n", encoding="utf-8") + + result = run_cmd("--install", home=tmp_path) + assert result.returncode == 0, result.stderr + assert mine.is_file() and mine.read_bytes() == before, ( + "a file whose digest the receipt does not recognise was deleted on the " + "strength of the header substring the receipt exists to replace:\n" + + result.stdout) + assert f"{name}: RETIRED" in result.stdout + assert "no longer holds the bytes this installer's receipt recorded" \ + in result.stdout, result.stdout + assert f'rm -f -- "{mine}"' in result.stdout, ( + "the act is the person's, so the line is printed filled in:\n" + + result.stdout) + assert [row for row in receipt_rows(tmp_path) if row[1] == str(mine)], ( + "the row of a file that is still there was dropped:\n" + + receipt.read_text(encoding="utf-8")) + + +def path_without(tmp_path: Path, *names: str) -> str: + """A `$PATH` on which none of `names` EXISTS, not one where they fail. + + A directory of symlinks to every executable the real `$PATH` carries + except those names, in the real `$PATH`'s own order. A shim that exits 1 + (see `test_a_digest_it_cannot_take_is_a_note_too`) is a tool that FAILS; + this is a machine that has none, which is the other thing a reviewer + means by "a missing digest tool", and the two reach `sha256_of` through + different arms. `bash`, `cp`, `jq` and the rest are all still found, so + the install itself runs exactly as it does everywhere else. + """ + farm = tmp_path / "path-without" + farm.mkdir() + for directory in os.environ["PATH"].split(os.pathsep): + try: + entries = sorted(os.listdir(directory)) + except OSError: + continue + for entry in entries: + source = os.path.join(directory, entry) + link = farm / entry + if (entry in names or link.is_symlink() + or not (os.path.isfile(source) + and os.access(source, os.X_OK))): + continue + link.symlink_to(source) + found = {name: shutil.which(name, path=str(farm)) for name in names} + assert not any(found.values()), f"the farm still answers for {found}" + return str(farm) + + +@pytest.mark.parametrize("how", ["absent", "failing"]) +@pytest.mark.parametrize("name", RETIRED) +@NEEDS_JQ +def test_a_row_it_cannot_verify_is_named_and_left_never_removed(tmp_path, name, how): + """A ROW THAT CANNOT BE CHECKED IS NOT "NO ROW" (Copilot on #103, + `openRepoTools:711`). + + `receipt_verdict` answered `unknown` when the digest could not be taken, + which is also what it answers when there is no row at all — so the header + fallback ran and REMOVED the file. On a host with a missing or failing + digest tool that is an edited, receipt-tracked command that still carries + the header being deleted, which is the ownership mistake the receipt + exists to prevent. The header is for "no matching row", and nothing else. + + The file carries the header, and its row names bytes it does not have — + the exact case where the header would have been the only thing standing + between the file and `rm`. It is left byte for byte and named with the + `rm` printed, whether the tool is ABSENT from `$PATH` or merely FAILING, + and the receipt is not touched: the row is about a file that is still + there. + """ + bin_dir = tmp_path / ".local" / "bin" + bin_dir.mkdir(parents=True) + mine = bin_dir / name + mine.write_text( + "#!/usr/bin/env bash\n" + "# Installed on PATH by `openRepoTools --install`, and then edited.\n" + "echo my own edit\n", encoding="utf-8") + mine.chmod(0o755) + before = mine.read_bytes() + receipt = receipt_path(tmp_path) + receipt.parent.mkdir(parents=True) + seeded = (f"{name}\t{mine}\t{hashlib.sha256(b'the bytes it placed').hexdigest()}" + "\t2026-09-15T04:04:16Z\n") + receipt.write_text(seeded, encoding="utf-8") + if how == "absent": + path = path_without(tmp_path, "sha256sum", "shasum") + else: + shim = tmp_path / "shims" + shim.mkdir() + for tool in ("sha256sum", "shasum"): + (shim / tool).write_text("#!/bin/sh\nexit 1\n", encoding="utf-8") + (shim / tool).chmod(0o755) + path = f"{shim}{os.pathsep}{os.environ['PATH']}" + + result = run_cmd("--install", home=tmp_path, env={"PATH": path}) + assert result.returncode == 0, result.stdout + result.stderr + assert mine.is_file() and mine.read_bytes() == before, ( + "a file with a receipt row that could not be checked was deleted on " + "the strength of the header the receipt exists to replace:\n" + + result.stdout) + retired = [line for line in result.stdout.splitlines() + if line.startswith(f"{name}: RETIRED")] + assert len(retired) == 1, result.stdout + assert str(mine) in retired[0], "the line names the destination" + assert "could not be checked" in retired[0], retired[0] + assert "no digest tool" in retired[0], retired[0] + assert f'rm -f -- "{mine}"' in retired[0], ( + "the act is the person's, so the line is printed filled in:\n" + + retired[0]) + assert "removed from" not in result.stdout + assert receipt.read_text(encoding="utf-8") == seeded, ( + "the receipt was rewritten by a run that could digest nothing") + + +@NEEDS_JQ +def test_a_receipt_it_cannot_write_is_a_note_and_never_a_refusal(tmp_path): + """FAIL CLOSED, OUT LOUD, IN ONE LINE, AND CARRY ON (#57). + + The commands, the skills and the hook entries are what a person asked for. + A record of them this installer could not write is its problem and not + theirs — and the header marker is still there for a later retirement to + fall back to, which is exactly the population that fallback exists for. So + the install SUCCEEDS and says where it could not write, why, and what that + costs. + + A regular file where the data directory goes, because that is a `mkdir -p` + that cannot succeed for any user, root included. + """ + blocked = tmp_path / "blocked" + blocked.write_text("a file where the data directory goes\n", + encoding="utf-8") + result = run_cmd("--install", home=tmp_path, + env={"OPENREPOTOOLS_DATA_DIR": str(blocked / "data")}) + assert result.returncode == 0, result.stdout + result.stderr + for name in INSTALLED: + assert (tmp_path / ".local" / "bin" / name).is_file(), name + assert f"openRepoTools: {len(INSTALLED)} of {len(INSTALLED)} placed" \ + in result.stdout + assert f"receipt: NOT written to {blocked / 'data' / 'installed.tsv'}" \ + in result.stdout, result.stdout + assert f"{blocked} is not a directory" in result.stdout, ( + "the note says WHY, because a person who cannot see the reason cannot " + f"fix it:\n{result.stdout}") + assert "falls back to the `Installed on PATH by` header" in result.stdout + assert "$OPENREPOTOOLS_DATA_DIR" in result.stdout, ( + "and names the way out") + assert "REFUSED" not in result.stderr + + +@NEEDS_JQ +def test_a_receipt_that_is_a_symlink_is_not_written_through(tmp_path): + """THE SAME RULE EVERY OTHER PATH IN THIS FILE IS HELD TO. + + `mv -f` replaces the LINK rather than what it points at, so a receipt + written over one silently swaps a person's link for a regular file — which + is what `cp` through a link does, in the other direction. It is named and + left, like every other path this installer did not make, and the install is + untouched. + """ + data = tmp_path / "data" + data.mkdir() + elsewhere = tmp_path / "somewhere-else.tsv" + elsewhere.write_text("mine\n", encoding="utf-8") + (data / "installed.tsv").symlink_to(elsewhere) + + result = run_cmd("--install", home=tmp_path, + env={"OPENREPOTOOLS_DATA_DIR": str(data)}) + assert result.returncode == 0, result.stdout + result.stderr + assert (data / "installed.tsv").is_symlink(), ( + "the link was replaced by a regular file:\n" + result.stdout) + assert elsewhere.read_text(encoding="utf-8") == "mine\n", ( + "and the bytes were written through it") + assert f"receipt: NOT written to {data / 'installed.tsv'}" in result.stdout + assert f"it is a symlink to {elsewhere}" in result.stdout, result.stdout + for name in INSTALLED: + assert (tmp_path / ".local" / "bin" / name).is_file(), name + + +@NOT_ROOT +@NEEDS_JQ +def test_a_receipt_it_cannot_read_is_left_whole_and_its_rows_are_not_lost(tmp_path): + """A RECEIPT THAT EXISTS AND CANNOT BE OPENED IS NOT AN EMPTY ONE (Copilot + round 4 on #103, `openRepoTools:575`). + + `receipt_rows_except` ended in an unconditional `return 0`, so a receipt + whose `done <"$1"` redirection failed reported a clean scan of no rows — + and `receipt_record` then REPLACED it with this run's rows alone, losing + every row about a copy this run did not place. The read failure now + propagates: the caller prints its "could not be read" note and the old + receipt is not touched. + + The receipt is made mode 000 and seeded with a row about a file this run + does not place, so that row's survival is the proof. The install itself is + whole — a receipt that cannot be maintained is a note and never a refusal + (#57) — and the receipt is read back after the mode is restored. + """ + receipt = receipt_path(tmp_path) + receipt.parent.mkdir(parents=True) + elsewhere = tmp_path / "bin-elsewhere" / "park" + elsewhere.parent.mkdir(parents=True) + elsewhere.write_text("#!/usr/bin/env bash\necho an older copy\n", + encoding="utf-8") + seeded = (f"park\t{elsewhere}" + f"\t{hashlib.sha256(elsewhere.read_bytes()).hexdigest()}" + "\t2026-09-15T04:04:16Z\n") + receipt.write_text(seeded, encoding="utf-8") + receipt.chmod(0o000) + try: + result = run_cmd("--install", home=tmp_path) + finally: + receipt.chmod(0o600) + assert result.returncode == 0, result.stdout + result.stderr + assert "REFUSED" not in result.stderr + for name in INSTALLED: + assert (tmp_path / ".local" / "bin" / name).is_file(), name + assert f"receipt: NOT written to {receipt}" in result.stdout, result.stdout + assert "the rows already there could not be read" in result.stdout, ( + "the note says WHY:\n" + result.stdout) + assert receipt.read_text(encoding="utf-8") == seeded, ( + "an unreadable receipt was replaced, and the rows in it are gone") + assert [p.name for p in receipt.parent.iterdir()] == ["installed.tsv"], ( + "a temporary was left in the data directory") + + +@pytest.mark.parametrize("name", RETIRED) +@NOT_ROOT +@NEEDS_JQ +def test_a_receipt_it_cannot_read_never_lets_the_header_remove_a_file(tmp_path, name): + """UNREADABLE IS NOT "NO ROW" (Copilot round 4 on #103, + `openRepoTools:716`). + + `receipt_verdict` scanned the receipt through a `done <"$file"` whose + failure nothing read, so a receipt that exists and cannot be opened looked + exactly like one that was scanned and has nothing to say about this path — + `unknown`, and the header fallback removed an edited file. `unknown` is now + for a scan that RAN and found no row; a receipt that cannot be opened + answers `unreadable`, and the retirement names the receipt, names and + leaves the file, and prints the `rm`, as it does for a row it cannot + digest. + + The file carries the header and its row names other bytes, so the header + is the only thing between it and `rm`. + """ + bin_dir = tmp_path / ".local" / "bin" + bin_dir.mkdir(parents=True) + mine = bin_dir / name + mine.write_text( + "#!/usr/bin/env bash\n" + "# Installed on PATH by `openRepoTools --install`, and then edited.\n" + "echo my own edit\n", encoding="utf-8") + mine.chmod(0o755) + before = mine.read_bytes() + receipt = receipt_path(tmp_path) + receipt.parent.mkdir(parents=True) + seeded = (f"{name}\t{mine}\t{hashlib.sha256(b'the bytes it placed').hexdigest()}" + "\t2026-09-15T04:04:16Z\n") + receipt.write_text(seeded, encoding="utf-8") + receipt.chmod(0o000) + try: + result = run_cmd("--install", home=tmp_path) + finally: + receipt.chmod(0o600) + assert result.returncode == 0, result.stdout + result.stderr + assert mine.is_file() and mine.read_bytes() == before, ( + "a receipt that could not be opened was read as having no row, and the " + "header removed an edited file:\n" + result.stdout) + retired = [line for line in result.stdout.splitlines() + if line.startswith(f"{name}: RETIRED")] + assert len(retired) == 1, result.stdout + assert str(receipt) in retired[0], "the line names the receipt" + assert "could not be read" in retired[0], retired[0] + assert str(mine) in retired[0], "and the destination" + assert f'rm -f -- "{mine}"' in retired[0], ( + "the act is the person's, so the line is printed filled in:\n" + + retired[0]) + assert "removed from" not in result.stdout + assert receipt.read_text(encoding="utf-8") == seeded + + +@NEEDS_JQ +def test_a_digest_it_cannot_take_is_a_note_too(tmp_path): + """AND NEITHER DIGEST TOOL IS A HARD DEPENDENCY. + + `sha256sum` is GNU and a stock macOS ships none; `shasum -a 256` is the + spelling that is there instead. A machine that can answer with NEITHER — or + one where the call fails, which is what the shims here are — still gets its + commands, its skills and its hook entries, and the note says why the record + of them is missing. An installer that refused to install because it could + not write its own bookkeeping would have the priorities backwards. + """ + shim = tmp_path / "shims" + shim.mkdir() + for name in ("sha256sum", "shasum"): + (shim / name).write_text("#!/bin/sh\nexit 1\n", encoding="utf-8") + (shim / name).chmod(0o755) + result = run_cmd("--install", home=tmp_path, + env={"PATH": f"{shim}{os.pathsep}{os.environ['PATH']}"}) + assert result.returncode == 0, result.stdout + result.stderr + for name in INSTALLED: + assert (tmp_path / ".local" / "bin" / name).is_file(), name + assert not receipt_path(tmp_path).exists(), ( + "a receipt was written out of digests nothing could take:\n" + + result.stdout) + assert f"receipt: NOT written to {receipt_path(tmp_path)}" in result.stdout + assert "could not be digested" in result.stdout, result.stdout + assert "REFUSED" not in result.stderr + + +@pytest.mark.parametrize("character", ["\t", "\n"], ids=["tab", "newline"]) +@NEEDS_JQ +def test_a_bin_dir_with_a_tab_or_newline_gets_no_receipt_and_a_whole_install(tmp_path, character): + """THE FAIL-CLOSED HALF OF THE TSV GUARD (Copilot on #103, + `openRepoTools:425`, which asked for the test this is). + + A receipt is a tab-separated file with no escape, and + `$OPENREPOTOOLS_BIN_DIR` is a path a person chooses. A destination + carrying a TAB would be silently one column too many and one carrying a + NEWLINE silently two rows — and a receipt that quietly lacks exactly the + file a retirement will ask about is worse than none. So the whole receipt + is not written, out loud, in one line naming the reason; and since a + receipt that cannot be written is a note and never a refusal (#57), the + install itself is complete. + + Both characters are accepted by the planner (nothing before the receipt + refuses a directory over what is in its name), so both are driven here. + The receipt that is already there is seeded with a row for a file that + does not exist, which any rewrite would DROP — so byte-for-byte equality + afterwards is the proof it was not replaced — and nothing but it may be + in the data directory, so no temporary was left behind either. + """ + bin_dir = tmp_path / f"bin{character}dir" + receipt = receipt_path(tmp_path) + receipt.parent.mkdir(parents=True) + seeded = ("# a receipt that somebody else's run wrote\n" + f"a-word-that-left\t{tmp_path / 'nowhere'}" + f"\t{hashlib.sha256(b'the bytes it placed').hexdigest()}" + "\t2026-09-15T04:04:16Z\n") + receipt.write_text(seeded, encoding="utf-8") + + result = run_cmd("--install", home=tmp_path, + env={"OPENREPOTOOLS_BIN_DIR": str(bin_dir)}) + assert result.returncode == 0, result.stdout + result.stderr + assert "REFUSED" not in result.stderr + for name in INSTALLED: + assert (bin_dir / name).is_file(), f"{name} was not placed" + assert f"openRepoTools: {len(INSTALLED)} of {len(INSTALLED)} placed" \ + in result.stdout + assert receipt.read_text(encoding="utf-8") == seeded, ( + "the receipt was replaced by a run whose destinations cannot be " + "written as a tab-separated row") + assert [p.name for p in receipt.parent.iterdir()] == ["installed.tsv"], ( + "a temporary was left in the data directory") + assert f"receipt: NOT written to {receipt}" in result.stdout, result.stdout + assert "has a tab or a newline in it and this file is tab-separated" \ + in result.stdout, ( + "the note says WHY, because a person who cannot see the reason " + f"cannot fix it:\n{result.stdout}") + assert "placed files recorded" not in result.stdout + + +@NEEDS_JQ +def test_installing_twice_leaves_no_duplicate_rows(tmp_path): + """A ROW'S SUBJECT IS ITS DESTINATION, AND A RUN REPLACES THE ROW OF EVERY + DESTINATION IT PLACED. + + An installer that APPENDED would grow a file by one row per placed file + every run and would answer a retirement out of whichever one it read first. + + WHAT DOES CHANGE ON THE SECOND RUN IS THE UTC, and that is deliberate: the + stamp says when the run RECORDED the row, not when those bytes were first + placed, which is the honest reading of a column written by a run that + verified a file it did not rewrite. `test_installing_twice_changes_nothing` + is about the bin directory and the artifacts — the lines each placement + prints — and the receipt is not one of them. + """ + assert run_cmd("--install", home=tmp_path).returncode == 0 + first = receipt_rows(tmp_path) + second_run = run_cmd("--install", home=tmp_path) + assert second_run.returncode == 0, second_run.stderr + second = receipt_rows(tmp_path) + assert len(second) == len(first) == ARTIFACTS - HOOK_ENTRIES + assert len({row[1] for row in second}) == len(second), ( + "a destination has two rows:\n" + + receipt_path(tmp_path).read_text(encoding="utf-8")) + assert {row[:3] for row in second} == {row[:3] for row in first}, ( + "the second run changed a name or a digest") + + +@NEEDS_JQ +def test_a_row_whose_file_is_gone_is_not_kept(tmp_path): + """THE WINDOW CLOSES BY ITSELF (Copilot round 1 on #103). + + `receipt_forget` runs AFTER the `rm` it accompanies, so a write that fails + in the instant between them would leave the receipt naming a path this + installer's copy has left — and a row nothing ever clears is a row that + answers a question about whatever somebody puts there next. The merge drops + a row whose file is gone, so the next `--install` repairs it whatever + happened, and the receipt does not grow for ever with the bin directories + and profile roots people delete. + """ + assert run_cmd("--install", home=tmp_path).returncode == 0 + receipt = receipt_path(tmp_path) + ghost = tmp_path / ".local" / "bin" / "a-word-that-left" + with receipt.open("a", encoding="utf-8") as handle: + handle.write(f"a-word-that-left\t{ghost}" + f"\t{hashlib.sha256(b'the bytes it placed').hexdigest()}" + "\t2026-09-15T04:04:16Z\n") + assert len(receipt_rows(tmp_path)) == ARTIFACTS - HOOK_ENTRIES + 1 + + result = run_cmd("--install", home=tmp_path) + assert result.returncode == 0, result.stderr + assert not [row for row in receipt_rows(tmp_path) if row[1] == str(ghost)], ( + "a row for a path with nothing at it survived an install:\n" + + receipt.read_text(encoding="utf-8")) + assert len(receipt_rows(tmp_path)) == ARTIFACTS - HOOK_ENTRIES, ( + "and the rows of the files that ARE there were kept") + + +@pytest.mark.parametrize("kind", ["directory", "symlink"]) +@NEEDS_JQ +def test_a_row_whose_path_is_no_longer_a_regular_file_is_not_kept(tmp_path, kind): + """AND WHAT IS KEPT IS WHAT COULD HAVE BEEN WRITTEN (Copilot round 2 on + #103). + + A row is evidence about a REGULAR FILE this installer placed — that is + `receipt_add`'s own test — so a destination that has become a directory or + a symlink is a path the file the row is about is not at. The first shape of + the rule above asked only "is there anything at all at that path", which + kept those rows; the two halves now spell one test. + """ + assert run_cmd("--install", home=tmp_path).returncode == 0 + gone = tmp_path / "bin-elsewhere" / "park" + gone.parent.mkdir(parents=True) + gone.write_text("#!/usr/bin/env bash\necho an older copy\n", encoding="utf-8") + receipt = receipt_path(tmp_path) + with receipt.open("a", encoding="utf-8") as handle: + handle.write(f"park\t{gone}" + f"\t{hashlib.sha256(gone.read_bytes()).hexdigest()}" + "\t2026-09-15T04:04:16Z\n") + gone.unlink() + if kind == "directory": + gone.mkdir() + else: + gone.symlink_to(tmp_path / ".local" / "bin" / "park") + + result = run_cmd("--install", home=tmp_path) + assert result.returncode == 0, result.stderr + assert not [row for row in receipt_rows(tmp_path) if row[1] == str(gone)], ( + f"a row was kept for a path that is now a {kind}:\n" + + receipt.read_text(encoding="utf-8")) + assert len(receipt_rows(tmp_path)) == ARTIFACTS - HOOK_ENTRIES + + +@NEEDS_JQ +def test_the_receipt_keeps_the_rows_of_a_directory_it_no_longer_writes(tmp_path): + """OTHER ROWS ARE KEPT, and the reason is the retirement. + + A person who moves `$OPENREPOTOOLS_BIN_DIR` still has the copies in the old + one, and a later retirement that walks that directory is exactly the run + that needs this evidence. A receipt keyed on the NAME would have dropped + those rows the moment the same name was placed somewhere else. + """ + first_dir = tmp_path / "bin-one" + second_dir = tmp_path / "bin-two" + assert run_cmd("--install", home=tmp_path, + env={"OPENREPOTOOLS_BIN_DIR": str(first_dir)}).returncode == 0 + assert run_cmd("--install", home=tmp_path, + env={"OPENREPOTOOLS_BIN_DIR": str(second_dir)}).returncode == 0 + destinations = {row[1] for row in receipt_rows(tmp_path)} + for name in INSTALLED: + assert str(first_dir / name) in destinations, ( + f"the row for the copy still in {first_dir} was dropped") + assert str(second_dir / name) in destinations, name + # The skill and command files are at the same paths both times, so they are + # REPLACED rather than added; what the second directory adds is one more + # row per file in `INSTALLED`, on top of everything the first run recorded. + assert len(receipt_rows(tmp_path)) == ARTIFACTS - HOOK_ENTRIES + len(INSTALLED) + + +@NEEDS_JQ +def test_a_relative_bin_dir_is_recorded_absolute_and_asked_about_from_anywhere(tmp_path): + """THE RECEIPT NAMES A FILE, NOT A SPELLING OF ONE (Copilot on #103, + `openRepoTools:524`). + + `$OPENREPOTOOLS_BIN_DIR=bin` is a directory the planner accepts, and it is + a different directory from every working directory it is run in. The first + shape recorded `bin/park` exactly as supplied, so a later run from another + directory compared the same string against ITS `bin/` — hashing, and + removing, a file that was never the one the row was written for. Every + destination is now recorded as a CANONICAL ABSOLUTE path, and every read + resolves the target the same way before it compares. + + Here: an install from `here` with the relative `bin` leaves only absolute + rows (and drops the old-shape relative row seeded in front of it, which is + a row no working directory can honestly answer). Then a run from `there` + that names the SAME directory a different way, `../here/bin`, retires the + `restart` in `here/bin` on the strength of its absolute row — and leaves + the user's own `restart` in `there/bin`, which has no row and no marker. + """ + here = tmp_path / "here" + there = tmp_path / "there" + here.mkdir() + (there / "bin").mkdir(parents=True) + receipt = receipt_path(tmp_path) + receipt.parent.mkdir(parents=True) + receipt.write_text( + f"park\tbin/park\t{hashlib.sha256(b'an older park').hexdigest()}" + "\t2026-09-15T04:04:16Z\n", encoding="utf-8") + + first = run_cmd("--install", home=tmp_path, + env={"OPENREPOTOOLS_BIN_DIR": "bin"}, cwd=here) + assert first.returncode == 0, first.stdout + first.stderr + rows = receipt_rows(tmp_path) + assert all(row[1].startswith("/") for row in rows), ( + "a destination was recorded as supplied:\n" + + receipt.read_text(encoding="utf-8")) + assert str(here / "bin" / "park") in {row[1] for row in rows} + assert len(rows) == ARTIFACTS - HOOK_ENTRIES, ( + "the old-shape relative row was kept beside the absolute ones") + + stale = here / "bin" / "restart" + stale.write_text("#!/usr/bin/env bash\n# restart, with no banner of any kind\n" + "echo stale\n", encoding="utf-8") + stale.chmod(0o755) + decoy = there / "bin" / "restart" + decoy.write_text("#!/usr/bin/env bash\n# my own restart, nothing to do with " + "that installer\necho mine\n", encoding="utf-8") + decoy.chmod(0o755) + before = decoy.read_bytes() + with receipt.open("a", encoding="utf-8") as handle: + handle.write(f"restart\t{stale}" + f"\t{hashlib.sha256(stale.read_bytes()).hexdigest()}" + "\t2026-09-15T04:04:16Z\n") + + second = run_cmd("--install", home=tmp_path, + env={"OPENREPOTOOLS_BIN_DIR": "../here/bin"}, cwd=there) + assert second.returncode == 0, second.stdout + second.stderr + assert not stale.exists(), ( + "the row names this file by its absolute path and its digest matches, " + "and a run from another directory did not recognise it:\n" + + second.stdout) + assert "receipt carries its digest" in second.stdout, second.stdout + assert decoy.is_file() and decoy.read_bytes() == before, ( + "the file in the OTHER directory's bin was removed") + assert not [row for row in receipt_rows(tmp_path) if row[1] == str(stale)] + + +@NEEDS_JQ +def test_a_relative_row_never_names_another_directorys_file(tmp_path): + """THE OTHER HALF OF THE SAME FINDING: A ROW WRITTEN AS `bin/restart` IS + NOT EVIDENCE ABOUT ANY `bin/restart`. + + This is the deletion Copilot described, set up directly — the row's + digest is the digest of THIS directory's file, because that is what a + row recorded from the other directory's `bin/` would also say about a + same-named file here. The old comparison was string against string, so it + matched and removed a file the row was never about. A relative row is + never matched now, and `receipt_rows_except` does not keep one, so it + cannot linger and answer the next run either. + """ + there = tmp_path / "there" + (there / "bin").mkdir(parents=True) + mine = there / "bin" / "restart" + mine.write_text("#!/usr/bin/env bash\n# my own restart, nothing to do with " + "that installer\necho mine\n", encoding="utf-8") + mine.chmod(0o755) + before = mine.read_bytes() + receipt = receipt_path(tmp_path) + receipt.parent.mkdir(parents=True) + receipt.write_text( + f"restart\tbin/restart\t{hashlib.sha256(before).hexdigest()}" + "\t2026-09-15T04:04:16Z\n", encoding="utf-8") + + result = run_cmd("--install", home=tmp_path, + env={"OPENREPOTOOLS_BIN_DIR": "bin"}, cwd=there) + assert result.returncode == 0, result.stdout + result.stderr + assert mine.is_file() and mine.read_bytes() == before, ( + "a file was removed on the strength of a row that named a RELATIVE " + "path, which is a row about whichever directory the run happened to " + "be in:\n" + result.stdout) + assert "is NOT this installer's copy" in result.stdout, result.stdout + assert all(row[1].startswith("/") for row in receipt_rows(tmp_path)), ( + "a relative row outlived the run:\n" + + receipt.read_text(encoding="utf-8")) + + +@NEEDS_JQ +def test_a_relative_data_dir_is_resolved_and_one_receipt_is_read_from_anywhere(tmp_path): + """THE RECEIPT'S OWN DIRECTORY IS CANONICAL TOO (Copilot round 4 on #103, + `openRepoTools:365`). + + `$OPENREPOTOOLS_DATA_DIR=data` is accepted, and `receipt_file` handed that + string straight to every reader and writer, so the one printed path was + `data/installed.tsv` — a spelling that means a different file from every + working directory. The directory is now resolved (`mkdir -p` first, where + this run is the one making it) and every read, write and line goes through + the resolved path. + + WHAT RESOLVING CANNOT DO is make `data` mean the same directory from two + working directories — a relative value names a different one from each, and + that is inherent in being relative. So the run SAYS so, beside the line + that names where the receipt went: the absolute path it resolved to and the + way out. Here: an install from `here` with `data` writes + `here/data/installed.tsv` and prints it absolute, with that note; a run from + `there` that names the same directory another way, `../here/data`, reads + the SAME receipt — the row for `restart` is found and the file removed — + and no second receipt appears anywhere. + """ + here = tmp_path / "here" + there = tmp_path / "there" + here.mkdir() + there.mkdir() + first = run_cmd("--install", home=tmp_path, + env={"OPENREPOTOOLS_DATA_DIR": "data"}, cwd=here) + assert first.returncode == 0, first.stdout + first.stderr + receipt = here / "data" / "installed.tsv" + assert receipt.is_file(), first.stdout + assert (f"receipt: {len(receipt_rows_at(receipt))} placed files recorded " + f"in {receipt}") in first.stdout, ( + "the line names the receipt as a canonical absolute path:\n" + + first.stdout) + assert "a RELATIVE path" in first.stdout and f"resolved it to {receipt.parent}" \ + in first.stdout, ( + "a relative data directory is said out loud, with where it went:\n" + + first.stdout) + + stale = tmp_path / ".local" / "bin" / "restart" + stale.write_text("#!/usr/bin/env bash\n# restart, with no banner of any kind\n" + "echo stale\n", encoding="utf-8") + stale.chmod(0o755) + with receipt.open("a", encoding="utf-8") as handle: + handle.write(f"restart\t{stale}" + f"\t{hashlib.sha256(stale.read_bytes()).hexdigest()}" + "\t2026-09-15T04:04:16Z\n") + second = run_cmd("--install", home=tmp_path, + env={"OPENREPOTOOLS_DATA_DIR": "../here/data"}, cwd=there) + assert second.returncode == 0, second.stdout + second.stderr + assert not stale.exists(), ( + "the row is in the receipt the first run wrote and the second did not " + "read it:\n" + second.stdout) + assert "receipt carries its digest" in second.stdout, second.stdout + assert sorted(tmp_path.rglob("installed.tsv")) == [receipt], ( + "a second receipt appeared:\n" + + "\n".join(str(p) for p in tmp_path.rglob("installed.tsv"))) + assert not (there / "data").exists() + + @NEEDS_JQ def test_bin_dir_overrides_where_it_lands(tmp_path): result = run_cmd("--install", home=tmp_path, diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py index 6135acd..9ccb3ca 100644 --- a/tests/test_repo_hygiene.py +++ b/tests/test_repo_hygiene.py @@ -2127,9 +2127,27 @@ def test_readme_is_short_enough_to_be_read(): lines it buys in § "The lane tooling" and § "Install" fit inside 472. A cap is a budget and not a target: an entry that raised it by fifteen because fifteen lines were written would make the number mean nothing. + + 472 -> 484 on 2026-10-02, for #57 (the install receipt). Twelve lines: the + ten of the receipt paragraph in § "Install", the blank line after it, and + one row in the environment table for `$OPENREPOTOOLS_DATA_DIR`. They are + behaviour a person MEETS rather than prose about it. The receipt is the + only place a retirement's evidence is explained - what a row is, that a + digest which still matches is this installer's copy and is removed, that + one which has MOVED is the person's edit and is named and left with the + `rm` printed, and that rows are keyed by DESTINATION, so moving + `$OPENREPOTOOLS_BIN_DIR` keeps the old directory's evidence. 0600 and the + fallback are the two facts a person meeting a refused retirement needs: + the mode the file is born at, and that a path with no row is read by the + `Installed on PATH by` header as before. The paragraph was written at + eighteen lines and cut to ten before this entry; the cap is raised for the + facts above and for no arithmetic - the artifact count stays where + `tests/test_openrepotools_command.py` derives it, and not here. Every dated + entry above stays, and none of these twelve is over a line one of them + bought. """ lines = (REPO / "README.md").read_text().splitlines() - assert len(lines) <= 472, f"README.md is {len(lines)} lines; the cap is 472" + assert len(lines) <= 484, f"README.md is {len(lines)} lines; the cap is 484" #: A host-absolute path baked into a committed file (the estate's Rule 1):