#!/usr/bin/env bash # `panama update`: the routine command, and the promises that make it routine. # # An update that asks questions is an update nobody runs, and a machine nobody # updates drifts until the next reinstall. So the properties below are the # whole point of the command rather than details of it: # # 1. --upgrade NEVER runs the interview. This is the regression that started # the redesign: ./install asked the full first-install questionnaire every # time, including on a machine whose answers could not have changed. # 2. --upgrade runs exactly the stages that need no answer, and never # setup-identity or install-hardware. Both act only on interview answers. # 3. install-packages is gated on a content hash of the package lists, and # the hash is NOT recorded when the stage fails -- the same rule # panama-migrate applies to its markers, for the same reason. # 4. A conflicted `git stash pop` leaves no conflict markers in the tree. # Every dotfile here is a symlink into the checkout, so a half-merged file # is not something to fix later: it is live in ~/.config immediately, and # a broken .qml costs the running shell. # # Driven against fixture stages in a throwaway PANAMA_PATH, with sudo and # gsettings shimmed, so this never touches the machine running it. set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" installer="$repo_dir/install" panama="$repo_dir/bin/panama" findings=() note() { findings+=("$1"); } [[ -x "$installer" ]] || { printf 'update command contract: no installer at %s\n' "$installer" >&2; exit 1; } tmp="$(mktemp -d -t panama-update-contract.XXXXXX)" trap 'rm -rf "$tmp"' EXIT STAGE_NAMES=(install-packages link-dotfiles link-user change-settings link-vicinae-scripts setup-identity install-hardware) # A PANAMA_PATH that looks enough like the real one for install to run, and # records what it was asked to do instead of doing it. build_fixture() { local root="$1" packages_rc="${2:-0}" rm -rf "$root" mkdir -p "$root/bin" "$root/setup/scripts" "$root/setup/packages" \ "$root/config/dot/quickshell/scripts" cp "$installer" "$root/install" : >"$root/bin/ascii" printf 'base-package\n' >"$root/setup/packages/base" local stage for stage in "${STAGE_NAMES[@]}"; do cat >"$root/setup/scripts/$stage" <>"\$PANAMA_RAN" EOF chmod +x "$root/setup/scripts/$stage" done # The one stage whose exit code the caller wants to control. cat >"$root/setup/scripts/install-packages" <>"\$PANAMA_RAN" exit $packages_rc EOF chmod +x "$root/setup/scripts/install-packages" cat >"$root/setup/scripts/interview" <<'EOF' #!/usr/bin/env bash printf 'interview\n' >>"$PANAMA_RAN" : >"$PANAMA_ANSWERS" EOF chmod +x "$root/setup/scripts/interview" cat >"$root/bin/panama-migrate" <<'EOF' #!/usr/bin/env bash printf 'migrate %s\n' "${1:-run}" >>"$PANAMA_RAN" EOF chmod +x "$root/bin/panama-migrate" cat >"$root/config/dot/quickshell/scripts/panama-doctor" <<'EOF' #!/usr/bin/env bash printf 'doctor\n' >>"$PANAMA_RAN" EOF chmod +x "$root/config/dot/quickshell/scripts/panama-doctor" # Nothing that reaches the real machine. sudo would prompt in a test run, # and gsettings would genuinely change the tester's screensaver. mkdir -p "$root/shim" cat >"$root/shim/sudo" <<'EOF' #!/usr/bin/env bash [[ "${1:-}" == -v || "${1:-}" == -n ]] && exit 0 exit 0 EOF cat >"$root/shim/gsettings" <<'EOF' #!/usr/bin/env bash exit 0 EOF cat >"$root/shim/hostnamectl" <<'EOF' #!/usr/bin/env bash exit 0 EOF chmod +x "$root/shim"/* } # Run the fixture installer and echo what ran, one stage per line. run_install() { local root="$1"; shift : >"$root/ran" PATH="$root/shim:$PATH" \ PANAMA_PATH="$root" \ PANAMA_RAN="$root/ran" \ XDG_STATE_HOME="$root/state" \ bash "$root/install" "$@" >"$root/out" 2>&1 printf '%s' "$?" >"$root/rc" cat "$root/ran" } # ── 1. The interview never runs on an upgrade ──────────────────────────────── build_fixture "$tmp/a" ran="$(run_install "$tmp/a" --upgrade)" if grep -qx 'interview' <<<"$ran"; then note 'install --upgrade ran the interview, which is the whole regression this prevents' fi # And the control: a real install must still ask. build_fixture "$tmp/b" ran_install="$(run_install "$tmp/b")" if ! grep -qx 'interview' <<<"$ran_install"; then note 'a plain ./install no longer asks the interview, so a new machine is never configured' fi # ── 2. Exactly the answer-free stages ──────────────────────────────────────── for stage in install-packages link-dotfiles link-user change-settings link-vicinae-scripts; do grep -qx "$stage" <<<"$ran" || note "install --upgrade did not run $stage" done for stage in setup-identity install-hardware; do grep -qx "$stage" <<<"$ran" \ && note "install --upgrade ran $stage, which exists only to act on interview answers" done # A stage added to STAGES without a decision about which path owns it shows up # here, because this list is written down twice on purpose. mapfile -t declared < <(python3 - "$installer" <<'PY' import re, sys line = next(l for l in open(sys.argv[1], encoding="utf-8") if l.startswith("STAGES=")) print("\n".join(re.findall(r"[\w-]+", line)[1:])) PY ) for stage in "${declared[@]}"; do printf '%s\n' "${STAGE_NAMES[@]}" | grep -qx "$stage" \ || note "install declares a stage this contract has never heard of: $stage" done # ── 3. The packages hash gates the stage, and a failure does not record it ─── # Second run, nothing changed: the stage must be skipped. ran_again="$(run_install "$tmp/a" --upgrade)" grep -qx 'install-packages' <<<"$ran_again" \ && note 'install-packages ran again with the package lists unchanged' # --packages overrides the hash. ran_forced="$(run_install "$tmp/a" --upgrade --packages)" grep -qx 'install-packages' <<<"$ran_forced" \ || note '--packages did not force install-packages to run' # A changed list brings the stage back. printf 'another-package\n' >>"$tmp/a/setup/packages/base" ran_changed="$(run_install "$tmp/a" --upgrade)" grep -qx 'install-packages' <<<"$ran_changed" \ || note 'a changed package list did not bring install-packages back' # A failing stage must not record the hash, or the failure is hidden forever. build_fixture "$tmp/c" 1 run_install "$tmp/c" --upgrade >/dev/null if [[ -r "$tmp/c/state/panama/packages-hash" ]]; then note 'install-packages failed but its hash was recorded, so it will never be retried' fi # A full install always runs the stage, whatever any recorded hash says. build_fixture "$tmp/d" run_install "$tmp/d" --upgrade >/dev/null ran_full="$(run_install "$tmp/d")" grep -qx 'install-packages' <<<"$ran_full" \ || note 'a full ./install skipped install-packages because of a recorded hash' # ── 4. A conflicted pop never leaves markers in a live config ──────────────── # # Two halves. The first checks that git still behaves the way the design # depends on; the second checks that panama acts on it. Neither is worth much # without the other. conflict="$tmp/conflict" mkdir -p "$conflict" ( set -e cd "$conflict" git init -q up && cd up git config user.email contract@panama && git config user.name contract printf 'one\n' >f; git add -A; git commit -qm one cd "$conflict"; git clone -q up work; cd work git config user.email contract@panama && git config user.name contract cd "$conflict/up"; printf 'upstream\n' >f; git commit -qam two cd "$conflict/work"; printf 'local\n' >f git stash push --include-untracked -m contract >/dev/null git pull -q --ff-only git stash pop >/dev/null 2>&1 && exit 3 # a conflict was the point git reset -q --hard HEAD [[ -n "$(git stash list)" ]] || exit 4 # the stash must survive grep -q '<<<<<<<' f && exit 5 # and no markers may remain exit 0 ) >/dev/null 2>&1 case $? in 0) ;; 3) note 'the conflict fixture did not conflict, so this check proves nothing' ;; 4) note 'git no longer keeps the stash after a conflicted pop; panama update would lose work' ;; 5) note 'git reset --hard left conflict markers behind' ;; *) note 'the stash conflict fixture could not be built' ;; esac # panama update must act on that: reset the tree rather than leave the markers. body="$(sed -n '/^cmd_update()/,/^}/p' "$panama")" if [[ -z "$body" ]]; then note 'bin/panama has no cmd_update to check' else grep -q 'git stash push' <<<"$body" \ || note 'cmd_update does not stash local changes, so a pull can fail on a dirty tree' grep -q 'git reset --hard' <<<"$body" \ || note 'cmd_update does not reset after a failed pop, so conflict markers reach ~/.config' grep -q 'git pull --ff-only' <<<"$body" \ || note 'cmd_update does not pull with --ff-only' grep -q -- '--upgrade' <<<"$body" \ || note 'cmd_update does not hand off to install --upgrade, so it would ask the interview' fi # The two verbs stay separate: sync must never run the installer. sync_body="$(sed -n '/^cmd_sync()/,/^}/p' "$panama")" if [[ -z "$sync_body" ]]; then note 'bin/panama has no cmd_sync, so the git workflow lost its home' else grep -q 'install' <<<"$sync_body" \ && note 'cmd_sync runs the installer; committing and updating are separate jobs' grep -q 'git stash' <<<"$sync_body" \ && note 'cmd_sync stashes, which the commit-before-pull order exists to avoid' fi if (( ${#findings[@]} > 0 )); then printf 'update command contract: %d finding(s)\n' "${#findings[@]}" >&2 printf ' - %s\n' "${findings[@]}" >&2 exit 1 fi printf 'update command contract: PASS\n'