panama update: one command, no questions, and no gap it cannot see

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-25 07:22:12 -04:00
parent 8105849151
commit beed44dd87
7 changed files with 894 additions and 99 deletions
+258
View File
@@ -0,0 +1,258 @@
#!/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" <<EOF
#!/usr/bin/env bash
printf '%s\n' "$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" <<EOF
#!/usr/bin/env bash
printf 'install-packages\n' >>"\$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'