Files
Panama/tests/setup/update-command-contract
T

664 lines
27 KiB
Bash
Executable File

#!/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-skills link-user change-settings
link-vicinae-scripts setup-server link-server setup-identity
install-hardware)
PACKAGE_BEHAVIOR_INPUTS=(
setup/lib/artifact-provenance
setup/lib/chatgpt-package
setup/lib/extras-catalog
setup/lib/machine-role
)
copy_hash_inputs() {
local root="$1" source relative
while IFS= read -r -d '' source; do
relative="${source#"$repo_dir"/}"
mkdir -p "$(dirname "$root/$relative")"
cp -- "$source" "$root/$relative"
done < <(
find "$repo_dir/setup/packages" -maxdepth 1 -type f -print0
find "$repo_dir/setup/provenance" -type f -print0
)
for relative in "${PACKAGE_BEHAVIOR_INPUTS[@]}"; do
mkdir -p "$(dirname "$root/$relative")"
cp -- "$repo_dir/$relative" "$root/$relative"
done
}
# 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}" trust_rc="${3:-0}"
rm -rf "$root"
mkdir -p "$root/bin" "$root/setup/scripts" "$root/setup/packages" \
"$root/setup/lib" "$root/setup/provenance/keys" \
"$root/config/dot/quickshell/scripts"
cp "$installer" "$root/install"
: >"$root/bin/ascii"
copy_hash_inputs "$root"
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
if [[ "\${1:-}" == --trust-preflight ]]; then
printf 'trust-preflight\n' >>"\$PANAMA_RAN"
exit $trust_rc
fi
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
cat >"$root/shim/dnf" <<'EOF'
#!/usr/bin/env bash
printf 'dnf-transaction\n' >>"$PANAMA_RAN"
exit 0
EOF
for prerequisite in gum lspci mokutil fwupdmgr; do
ln -s gsettings "$root/shim/$prerequisite"
done
chmod +x "$root/shim"/*
}
# Run the fixture installer and echo what ran, one stage per line.
run_install() {
local root="$1"; shift
local status=0
: >"$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 || status=$?
cat "$root/ran"
return "$status"
}
run_hash() {
local root="$1"
sed -n '/^hash_packages() {/,/^}$/p' "$root/install" >"$root/hash-only"
printf 'set -uo pipefail\nhash_packages\n' >>"$root/hash-only"
PANAMA_PATH="$root" bash "$root/hash-only" 2>"$root/hash-only.err"
}
assert_hash_failure() {
local root="$1" description="$2" status=0 digest
digest="$(run_hash "$root")" || status=$?
[[ "$status" -ne 0 && -z "$digest" ]] \
|| note "$description produced a digest instead of failing closed"
}
# ── 1. The interview never runs on an upgrade ────────────────────────────────
build_fixture "$tmp/a"
install_status=0
ran="$(run_install "$tmp/a" --upgrade)" || install_status=$?
[[ "$install_status" -eq 0 ]] \
|| note "initial install --upgrade failed with status $install_status"
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"
install_status=0
ran_install="$(run_install "$tmp/b")" || install_status=$?
[[ "$install_status" -eq 0 ]] \
|| note "plain fixture install failed with status $install_status"
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-skills 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.
declared=()
if declared_output="$(python3 - "$installer" <<'PY'
import re
import sys
found = False
for line in open(sys.argv[1], encoding="utf-8"):
match = re.match(r'^\s*STAGES=\((.*)\)\s*$', line)
if match and "$" not in match.group(1):
found = True
print("\n".join(re.findall(r"[\w-]+", match.group(1))))
if not found:
print("install has no STAGES assignment", file=sys.stderr)
sys.exit(1)
PY
)"; then
while IFS= read -r stage; do
[[ -n "$stage" ]] && declared+=("$stage")
done <<<"$declared_output"
else
note 'could not read the install STAGES assignment'
fi
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 ───
package_inputs=()
while IFS= read -r -d '' input; do
package_inputs+=("${input#"$repo_dir"/}")
done < <(find "$repo_dir/setup/packages" -maxdepth 1 -type f -print0)
provenance_inputs=()
while IFS= read -r -d '' input; do
provenance_inputs+=("${input#"$repo_dir"/}")
done < <(find "$repo_dir/setup/provenance" -type f -print0)
(( ${#package_inputs[@]} > 0 )) \
|| note 'the current repository has no top-level package input to exercise'
(( ${#provenance_inputs[@]} > 0 )) \
|| note 'the current repository has no provenance input to exercise'
# Second run, nothing changed: the stage must be skipped.
install_status=0
ran_again="$(run_install "$tmp/a" --upgrade)" || install_status=$?
[[ "$install_status" -eq 0 ]] \
|| note "repeat install --upgrade failed with status $install_status"
grep -qx 'install-packages' <<<"$ran_again" \
&& note 'install-packages ran again with the package lists unchanged'
# --packages overrides the hash.
install_status=0
ran_forced="$(run_install "$tmp/a" --upgrade --packages)" || install_status=$?
[[ "$install_status" -eq 0 ]] \
|| note "install --upgrade --packages failed with status $install_status"
grep -qx 'install-packages' <<<"$ran_forced" \
|| note '--packages did not force install-packages to run'
# Every current package and provenance member is part of the state definition.
# Dynamically discovering them makes this fail when a new reviewed input is
# added but omitted from hash_packages.
for relative in "${package_inputs[@]}" "${provenance_inputs[@]}" \
'setup/scripts/install-packages' "${PACKAGE_BEHAVIOR_INPUTS[@]}"; do
printf 'changed %s\n' "$relative" >>"$tmp/a/$relative"
install_status=0
ran_input_changed="$(run_install "$tmp/a" --upgrade)" || install_status=$?
[[ "$install_status" -eq 0 ]] \
|| note "install --upgrade failed after changing $relative with status $install_status"
grep -qx 'install-packages' <<<"$ran_input_changed" \
|| note "a changed $relative did not bring install-packages back"
done
# A path-only change must invalidate state even when the file bytes are exact.
for relative in "${package_inputs[0]:-}" "${provenance_inputs[0]:-}"; do
[[ -n "$relative" ]] || continue
build_fixture "$tmp/path-rename"
run_install "$tmp/path-rename" --upgrade >/dev/null
mv -- "$tmp/path-rename/$relative" "$tmp/path-rename/$relative.renamed"
install_status=0
ran_renamed="$(run_install "$tmp/path-rename" --upgrade)" || install_status=$?
[[ "$install_status" -eq 0 ]] \
|| note "install --upgrade failed after renaming $relative with status $install_status"
grep -qx 'install-packages' <<<"$ran_renamed" \
|| note "renaming $relative without changing bytes did not bring install-packages back"
done
# Fixed hash inputs must not silently disappear or degrade into a directory or
# link. An unreadable package input also proves a failed content read cannot be
# hidden by the final digest command.
for fixed_input in setup/scripts/install-packages "${PACKAGE_BEHAVIOR_INPUTS[@]}"; do
for case_name in missing directory symlink unreadable; do
case_root="$tmp/hash-${fixed_input//\//-}-$case_name"
build_fixture "$case_root"
fixed_path="$case_root/$fixed_input"
case "$case_name" in
missing) rm -- "$fixed_path" ;;
directory) rm -- "$fixed_path"; mkdir -- "$fixed_path" ;;
symlink)
printf 'untrusted target\n' >"$case_root/untrusted-target"
rm -- "$fixed_path"
ln -s "$case_root/untrusted-target" "$fixed_path"
;;
unreadable) chmod 000 "$fixed_path" ;;
esac
assert_hash_failure "$case_root" "$fixed_input $case_name"
done
done
read_failure_root="$tmp/hash-package-read-failure"
build_fixture "$read_failure_root"
chmod 000 "$read_failure_root/${package_inputs[0]}"
assert_hash_failure "$read_failure_root" "${package_inputs[0]} unreadable"
# A hash failure is an installer failure, not a reason to skip the package
# stage and retain a stale stamp.
build_fixture "$tmp/hash-failure"
run_install "$tmp/hash-failure" --upgrade >/dev/null
cp -- "$tmp/hash-failure/state/panama/packages-hash" "$tmp/hash-failure/stamp-before"
rm -- "$tmp/hash-failure/setup/lib/artifact-provenance"
install_status=0
ran_hash_failure="$(run_install "$tmp/hash-failure" --upgrade)" || install_status=$?
[[ "$install_status" -ne 0 ]] \
|| note 'a failed package-state hash returned success'
grep -qx 'install-packages' <<<"$ran_hash_failure" \
&& note 'a failed package-state hash still ran install-packages'
cmp -s -- "$tmp/hash-failure/stamp-before" "$tmp/hash-failure/state/panama/packages-hash" \
|| note 'a failed package-state hash wrote a new packages-hash stamp'
# Inputs changed while install-packages was running were not the inputs it
# consumed at the start. Do not stamp the later bytes as successfully applied.
build_fixture "$tmp/hash-mid-stage-drift"
cat >"$tmp/hash-mid-stage-drift/setup/scripts/install-packages" <<'EOF'
#!/usr/bin/env bash
if [[ "${1:-}" == --trust-preflight ]]; then
printf 'trust-preflight\n' >>"$PANAMA_RAN"
exit 0
fi
printf 'install-packages\n' >>"$PANAMA_RAN"
printf '\nchanged during package installation\n' >>"$PANAMA_PATH/setup/lib/machine-role"
EOF
chmod +x "$tmp/hash-mid-stage-drift/setup/scripts/install-packages"
install_status=0
run_install "$tmp/hash-mid-stage-drift" --upgrade >/dev/null || install_status=$?
[[ "$install_status" -ne 0 ]] \
|| note 'mid-stage package input drift returned success'
[[ ! -e "$tmp/hash-mid-stage-drift/state/panama/packages-hash" ]] \
|| note 'mid-stage package input drift stamped bytes the stage did not start with'
# A failing stage must not record the hash, or the failure is hidden forever.
build_fixture "$tmp/c" 1
install_status=0
run_install "$tmp/c" --upgrade >/dev/null || install_status=$?
[[ "$install_status" -eq 1 ]] \
|| note "install --upgrade returned $install_status instead of the failing stage status"
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
grep -qx 'link-dotfiles' "$tmp/c/ran" \
|| note 'an ordinary package-stage failure no longer allows later safe stages'
# An invalid enabled Terra root is not an ordinary package failure. It must
# stop before the installer's bootstrap DNF and before every stage.
build_fixture "$tmp/terra-preflight-hard" 0 78
install_status=0
run_install "$tmp/terra-preflight-hard" >/dev/null || install_status=$?
[[ "$install_status" -eq 78 ]] \
|| note "initial Terra trust failure returned $install_status instead of 78"
asserted_preflight="$(<"$tmp/terra-preflight-hard/ran")"
[[ "$asserted_preflight" == trust-preflight ]] \
|| note "initial Terra trust failure allowed later work: ${asserted_preflight//$'\n'/,}"
# The trust verifier is itself mandatory. Losing its executable adapter must
# fail closed before interview, bootstrap, or stage work.
build_fixture "$tmp/terra-preflight-missing"
rm "$tmp/terra-preflight-missing/setup/scripts/install-packages"
install_status=0
run_install "$tmp/terra-preflight-missing" >/dev/null || install_status=$?
[[ "$install_status" -eq 78 ]] \
|| note "missing Terra trust verifier returned $install_status instead of 78"
[[ ! -s "$tmp/terra-preflight-missing/ran" ]] \
|| note 'missing Terra trust verifier allowed later work'
# The package stage repeats the preflight to close a configuration-change race.
# Its hard status must also stop link stages and install-hardware immediately.
build_fixture "$tmp/terra-stage-hard" 78 0
install_status=0
run_install "$tmp/terra-stage-hard" >/dev/null || install_status=$?
[[ "$install_status" -eq 78 ]] \
|| note "stage-time Terra trust failure returned $install_status instead of 78"
grep -qx 'install-packages' "$tmp/terra-stage-hard/ran" \
|| note 'stage-time Terra trust fixture never reached install-packages'
for suppressed in link-dotfiles link-skills link-user change-settings install-hardware dnf-transaction; do
grep -qx "$suppressed" "$tmp/terra-stage-hard/ran" \
&& note "stage-time Terra trust failure still ran $suppressed"
done
# A full install always runs the stage, whatever any recorded hash says.
build_fixture "$tmp/d"
install_status=0
run_install "$tmp/d" --upgrade >/dev/null || install_status=$?
[[ "$install_status" -eq 0 ]] \
|| note "hash-seeding install --upgrade failed with status $install_status"
install_status=0
ran_full="$(run_install "$tmp/d")" || install_status=$?
[[ "$install_status" -eq 0 ]] \
|| note "full fixture reinstall failed with status $install_status"
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 ────────────────
#
# The fixture also covers a clean fast-forward, installer status propagation,
# and the boundary between update and sync before forcing the conflict below.
# Make every ambient configuration source hostile before constructing the Git
# fixtures. A hermetic fixture overrides these values with its own empty state;
# consuming any of them either leaves a sentinel or prevents a commit.
hostile="$tmp/hostile-environment"
mkdir -p "$hostile/home" "$hostile/xdg-config" "$hostile/xdg-state" \
"$hostile/xdg-cache" "$hostile/xdg-data" "$hostile/hooks" \
"$hostile/template/hooks"
for profile in .bash_profile .bashrc .profile; do
cat >"$hostile/home/$profile" <<'EOF'
printf 'profile\n' >>"${PANAMA_HOSTILE_PROFILE_SENTINEL:?}"
EOF
done
cat >"$hostile/bash-env" <<'EOF'
printf 'BASH_ENV\n' >>"${PANAMA_HOSTILE_BASH_ENV_SENTINEL:?}"
EOF
cat >"$hostile/hooks/pre-commit" <<'EOF'
#!/usr/bin/env bash
printf 'global hook\n' >>"${PANAMA_HOSTILE_GIT_SENTINEL:?}"
exit 97
EOF
cat >"$hostile/template/hooks/pre-commit" <<'EOF'
#!/usr/bin/env bash
# PANAMA_HOSTILE_TEMPLATE_HOOK
printf 'template hook\n' >>"${PANAMA_HOSTILE_TEMPLATE_SENTINEL:?}"
exit 98
EOF
chmod +x "$hostile/hooks/pre-commit" "$hostile/template/hooks/pre-commit"
cat >"$hostile/global.gitconfig" <<EOF
[core]
hooksPath = $hostile/hooks
[commit]
gpgSign = true
[init]
templateDir = $hostile/template
EOF
cp "$hostile/global.gitconfig" "$hostile/system.gitconfig"
export HOME="$hostile/home"
export XDG_CONFIG_HOME="$hostile/xdg-config"
export XDG_STATE_HOME="$hostile/xdg-state"
export XDG_CACHE_HOME="$hostile/xdg-cache"
export XDG_DATA_HOME="$hostile/xdg-data"
export BASH_ENV="$hostile/bash-env"
export PANAMA_HOSTILE_PROFILE_SENTINEL="$hostile/profile-sourced"
export PANAMA_HOSTILE_BASH_ENV_SENTINEL="$hostile/bash-env-sourced"
export PANAMA_HOSTILE_GIT_SENTINEL="$hostile/global-config-sourced"
export PANAMA_HOSTILE_TEMPLATE_SENTINEL="$hostile/template-hook-sourced"
export GIT_CONFIG_NOSYSTEM=0
export GIT_CONFIG_SYSTEM="$hostile/system.gitconfig"
export GIT_CONFIG_GLOBAL="$hostile/global.gitconfig"
export GIT_CONFIG_COUNT=1
export GIT_CONFIG_KEY_0=core.hooksPath
export GIT_CONFIG_VALUE_0="$hostile/hooks"
export GIT_TEMPLATE_DIR="$hostile/template"
prepare_cli_fixture_environment() {
local root="$1"
mkdir -p "$root/home" "$root/xdg-config" "$root/xdg-state" \
"$root/xdg-cache" "$root/xdg-data" "$root/xdg-runtime" \
"$root/empty-templates" "$root/empty-hooks"
chmod 700 "$root/xdg-runtime"
}
run_cli_fixture_environment() {
local root="$1"
shift
env -u BASH_ENV -u ENV -u GIT_CONFIG_PARAMETERS \
-u GIT_CONFIG_KEY_0 -u GIT_CONFIG_VALUE_0 \
HOME="$root/home" \
XDG_CONFIG_HOME="$root/xdg-config" \
XDG_STATE_HOME="$root/xdg-state" \
XDG_CACHE_HOME="$root/xdg-cache" \
XDG_DATA_HOME="$root/xdg-data" \
XDG_RUNTIME_DIR="$root/xdg-runtime" \
GIT_CONFIG_NOSYSTEM=1 \
GIT_CONFIG_SYSTEM=/dev/null \
GIT_CONFIG_GLOBAL=/dev/null \
GIT_CONFIG_COUNT=0 \
GIT_TEMPLATE_DIR="$root/empty-templates" \
"$@"
}
fixture_git() {
local root="$1"
shift
run_cli_fixture_environment "$root" \
git -c commit.gpgSign=false -c tag.gpgSign=false \
-c core.hooksPath="$root/empty-hooks" "$@"
}
configure_fixture_repo() {
local root="$1" repository="$2"
fixture_git "$root" -C "$repository" config user.email contract@panama || return 1
fixture_git "$root" -C "$repository" config user.name contract || return 1
fixture_git "$root" -C "$repository" config commit.gpgSign false || return 1
fixture_git "$root" -C "$repository" config tag.gpgSign false || return 1
fixture_git "$root" -C "$repository" config core.hooksPath "$root/empty-hooks" || return 1
}
# Each fixture has the same three repositories as a real update: a bare remote,
# a clone that publishes upstream changes, and the machine clone being updated.
build_cli_fixture() (
local root="$1"
rm -rf "$root" || return 1
mkdir -p "$root" || return 1
prepare_cli_fixture_environment "$root" || return 1
fixture_git "$root" init -q --bare "$root/origin.git" || return 1
fixture_git "$root" -C "$root/origin.git" config core.hooksPath "$root/empty-hooks" || return 1
fixture_git "$root" clone -q "$root/origin.git" "$root/upstream" 2>/dev/null || return 1
configure_fixture_repo "$root" "$root/upstream" || return 1
mkdir -p "$root/upstream/bin" || return 1
cp "$panama" "$root/upstream/bin/panama" || return 1
cat >"$root/upstream/install" <<'EOF' || return 1
#!/usr/bin/env bash
printf '%s\n' "$*" >>"${PANAMA_UPDATE_FIXTURE_LOG:?}"
exit "${PANAMA_UPDATE_INSTALL_RC:-0}"
EOF
chmod +x "$root/upstream/bin/panama" "$root/upstream/install" || return 1
printf 'one\n' >"$root/upstream/f" || return 1
fixture_git "$root" -C "$root/upstream" add -A || return 1
fixture_git "$root" -C "$root/upstream" commit -qm initial || return 1
fixture_git "$root" -C "$root/upstream" push -qu origin HEAD || return 1
fixture_git "$root" clone -q "$root/origin.git" "$root/machine" || return 1
configure_fixture_repo "$root" "$root/machine" || return 1
)
advance_upstream() (
local root="$1" file="$2" contents="$3"
printf '%s\n' "$contents" >"$root/upstream/$file" || return 1
fixture_git "$root" -C "$root/upstream" add "$file" || return 1
fixture_git "$root" -C "$root/upstream" commit -qm "update $file" || return 1
fixture_git "$root" -C "$root/upstream" push -q || return 1
)
# This write fails before the later Git commands. The helper must return that
# failure rather than let a final successful push hide it.
helper_probe="$tmp/helper-failure"
if build_cli_fixture "$helper_probe"; then
if advance_upstream "$helper_probe" missing/child probe \
2>"$helper_probe/intermediate-failure.err"; then
note 'advance_upstream hid an intermediate fixture setup failure'
fi
else
note 'the fixture helper failure probe could not be built'
fi
clean="$tmp/clean-update"
if build_cli_fixture "$clean" && advance_upstream "$clean" release new; then
machine_before="$(fixture_git "$clean" -C "$clean/machine" rev-parse HEAD)"
upstream_after="$(fixture_git "$clean" -C "$clean/upstream" rev-parse HEAD)"
[[ "$machine_before" != "$upstream_after" ]] \
|| note 'the clean update fixture started current, so it cannot prove a fast-forward'
: >"$clean/install.log"
update_status=0
run_cli_fixture_environment "$clean" \
PANAMA_UPDATE_FIXTURE_LOG="$clean/install.log" \
"$clean/machine/bin/panama" update >"$clean/update.out" 2>&1 \
|| update_status=$?
[[ "$update_status" -eq 0 ]] \
|| note "panama update failed on a clean clone with status $update_status"
[[ "$(fixture_git "$clean" -C "$clean/machine" rev-parse HEAD)" == "$upstream_after" ]] \
|| note 'panama update did not fast-forward the clean machine clone'
grep -qx -- '--upgrade' "$clean/install.log" \
|| note 'panama update did not invoke the installer with --upgrade'
: >"$clean/install.log"
update_status=0
run_cli_fixture_environment "$clean" \
PANAMA_UPDATE_FIXTURE_LOG="$clean/install.log" PANAMA_UPDATE_INSTALL_RC=23 \
"$clean/machine/bin/panama" update >"$clean/failing-update.out" 2>&1 \
|| update_status=$?
[[ "$update_status" -eq 23 ]] \
|| note "panama update returned $update_status instead of installer status 23"
grep -qx -- '--upgrade' "$clean/install.log" \
|| note 'the failing update did not reach the fixture installer'
: >"$clean/install.log"
printf 'local sync\n' >"$clean/machine/synced"
sync_status=0
printf 'y\ncontract sync\n' \
| run_cli_fixture_environment "$clean" \
PANAMA_UPDATE_FIXTURE_LOG="$clean/install.log" \
"$clean/machine/bin/panama" sync >"$clean/sync.out" 2>&1 \
|| sync_status=$?
[[ "$sync_status" -eq 0 ]] \
|| note "panama sync failed in the local fixture with status $sync_status"
[[ ! -s "$clean/install.log" ]] \
|| note 'panama sync invoked the installer; sync and update are separate jobs'
else
note 'the clean update fixture could not be built'
fi
# A conflicted stash pop must not leave markers in the checkout. The failed pop
# keeps the stash, so the local version remains recoverable after the reset.
conflict="$tmp/conflict-update"
if build_cli_fixture "$conflict"; then
printf 'local\n' >"$conflict/machine/f"
if advance_upstream "$conflict" f upstream; then
: >"$conflict/install.log"
conflict_status=0
run_cli_fixture_environment "$conflict" \
PANAMA_UPDATE_FIXTURE_LOG="$conflict/install.log" \
"$conflict/machine/bin/panama" update >"$conflict/update.out" 2>&1 \
|| conflict_status=$?
[[ "$conflict_status" -eq 0 ]] \
|| note "panama update failed while recovering a stash conflict with status $conflict_status"
[[ "$(<"$conflict/machine/f")" == upstream ]] \
|| note 'panama update did not reset the conflicted file to the upstream version'
if fixture_git "$conflict" -C "$conflict/machine" \
grep -qE '^(<<<<<<<|=======|>>>>>>>)' -- .; then
note 'panama update left conflict markers in the machine checkout'
fi
[[ -n "$(fixture_git "$conflict" -C "$conflict/machine" stash list)" ]] \
|| note 'panama update dropped the stash after its conflicted pop'
recovered="$(fixture_git "$conflict" -C "$conflict/machine" \
show 'stash@{0}:f' 2>/dev/null)"
[[ "$recovered" == local ]] \
|| note 'the stash left by panama update does not contain the local version'
else
note 'the conflict fixture could not publish its upstream edit'
fi
else
note 'the conflict update fixture could not be built'
fi
for sentinel in profile-sourced bash-env-sourced global-config-sourced template-hook-sourced; do
[[ ! -e "$hostile/$sentinel" ]] \
|| note "the update Git fixture consumed hostile state: $sentinel"
done
template_copy="$(
find "$tmp" -path "$hostile" -prune -o \
-type f -path '*/hooks/pre-commit' \
-exec grep -lF 'PANAMA_HOSTILE_TEMPLATE_HOOK' {} + 2>/dev/null
)"
[[ -z "$template_copy" ]] \
|| note "the update Git fixture copied a hostile template hook: $template_copy"
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'