diff --git a/README.md b/README.md index a7cf0fc..72bf5d9 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,16 @@ git clone https://git.gbrown.org/gib/Panama.git ~/.local/share/Panama ``` Both are safe to run again: an existing clone is fast-forwarded rather than -replaced, and `install` is the upgrade path. +replaced. Once a machine exists, though, the command that keeps it current is +`panama update` — one command, and it never asks you anything: + +```sh +panama update +``` + +It pulls, applies any repairs this machine has not had, and runs the stages +below that need no answers. `./install` remains what it is: how a machine is +built, and how you change an answer you gave. `install` asks its questions first and then runs the stages in `setup/scripts/` in order, without stopping again: @@ -190,21 +199,39 @@ name it; **Open Project** lays it out again. Workspaces are recorded as positions rather than numbers, and opening a project claims free ones, so it never lands on top of what you are already doing. An application that refuses to open twice — Slack, Thunderbird, the browser — is -moved into place rather than launched again. Saved layouts are listed on the -Desktop settings page, which is also where they are removed. +moved into place rather than launched again. Saved layouts are listed on +Shell › Workspaces in Settings, which is also where they are removed. ## The `panama` command ```sh -panama update # review, commit and sync this repo +panama update # bring this machine up to date; asks nothing +panama sync # review, commit and push your changes to this repo panama edit # open it in Neovim panama doctor # what is actually running, not what was installed panama test # every contract, or a subset by pattern -panama upgrade # re-run ./install from anywhere +panama migrate # apply repairs this machine has not had yet +panama upgrade # re-run ./install from anywhere, interview and all panama apps # choose applications to install, by category panama app # applications no repository carries; build one by name ``` +`panama update` and `panama sync` are separate verbs on purpose. One acts on +the machine, the other on the repository. A single command that chose between +them by checking whether the working tree happened to be dirty would do a +different job depending on state nobody can see — and, worse, would never +update a machine belonging to somebody who had left a file edited. + +`panama update` stashes uncommitted work across the pull and restores it +afterwards. If restoring conflicts it resets the checkout and leaves the work +in the stash, saying so at the end of the run: every dotfile here is a symlink +into this repository, so a conflict marker is not a thing to fix at leisure. It +is live in `~/.config` the moment it is written. + +Only two things still need `./install`: a machine that does not exist yet, and +an answer you want to change. Adding a package to a list you already have is +`panama update`; adding an optional category is `panama apps`. + `panama apps` is the optional-application catalog, opened after the fact. The interview offers the same categories during `./install`, whole; this picks a category and then the applications inside it, so a machine can acquire Slack in diff --git a/bin/panama b/bin/panama index 0d837ee..0dd8833 100755 --- a/bin/panama +++ b/bin/panama @@ -5,15 +5,22 @@ # Author: Gabriel Brown # # Commands: -# update Commit & sync local changes (or just pull if clean) +# update Bring this machine up to date: pull, then install --upgrade +# sync Review, commit & push local changes to this repo # edit Open the Panama repo in Neovim # doctor Report what is actually running on this machine # test Run every contract under tests/ -# upgrade Re-run the installer from anywhere +# upgrade Re-run the installer from anywhere, interview included +# migrate Apply repairs this machine has not had yet # apps Choose applications to install, by category # app Build and install an application that no repository packages # help Show this help # +# update and sync are deliberately separate verbs. One acts on the machine, the +# other on the repository, and a single command that guessed between them by +# looking at whether the tree happened to be dirty would do a different job +# depending on state nobody can see. +# # Designed to grow: add new subcommands as cmd_ functions and # register them in the dispatcher / usage block below. @@ -64,15 +71,20 @@ ${BOLD}Usage:${RESET} $PROGRAM [options] ${BOLD}Commands:${RESET} - ${GREEN}update${RESET} Review, commit & sync local changes. If the working tree is - clean it simply runs 'git pull'. + ${GREEN}update${RESET} Bring this machine up to date. Pulls, then runs the stages + that need no questions asked. The routine command; safe to + re-run, and it never asks you anything. + ${GREEN}sync${RESET} Review, commit & push your changes to this repo. Uncommitted + work is committed before anything is fetched, so a moved + upstream is a rebase rather than a stash conflict. ${GREEN}edit${RESET} Open the Panama repo in Neovim. ${GREEN}doctor${RESET} Report what is actually running on this machine, rather than what was installed. Takes --summary for one line per check. ${GREEN}test${RESET} Run every contract under tests/. Give it a pattern to run a subset: 'panama test dock' runs the ones matching 'dock'. - ${GREEN}upgrade${RESET} Re-run ./install from anywhere. Safe: every stage is - idempotent and this is the documented upgrade path. + ${GREEN}upgrade${RESET} Re-run ./install from anywhere, interview and all. For a new + machine, or to change an answer you gave. Routine updates are + '$PROGRAM update', which asks nothing. ${GREEN}migrate${RESET} Apply repairs this machine has not had yet. The half of an upgrade that ./install cannot do, because installing only ever adds. Safe to re-run; nothing is applied twice. @@ -89,6 +101,8 @@ ${BOLD}Options:${RESET} ${BOLD}Examples:${RESET} $PROGRAM update + $PROGRAM update --packages + $PROGRAM sync $PROGRAM edit $PROGRAM doctor --summary $PROGRAM test dock @@ -112,25 +126,116 @@ require_git_repo() { # ---------------------------------------------------------------------------- # Command: update # ---------------------------------------------------------------------------- +# +# The routine command: bring THIS MACHINE up to date. Pull, then hand the rest +# to `install --upgrade`, which asks nothing. +# +# Most of an update needs no stage at all. Every dotfile is a symlink into this +# checkout, so an edit to an existing config/dot/** file is live the moment the +# pull returns -- there is nothing to apply. Stages earn their place when a pull +# brings something structural: a new dotfile directory to link, a file under +# config/copy/ to place as root, a new package, a gsettings change. Running the +# cheap ones every time is idempotent and takes seconds; working out which were +# needed is guesswork with a silent failure mode. +# +# Uncommitted work never blocks an update. It is stashed across the pull and +# restored afterwards -- and if restoring conflicts, the tree is reset rather +# than left holding conflict markers, because on this repository those markers +# are not something you fix at your leisure. They are live in ~/.config the +# instant they are written, and a half-merged .qml is a shell that will not +# parse. cmd_update() { require_git_repo cd "$PANAMA_DIR" info "Panama repo: ${BOLD}${PANAMA_DIR}${RESET}" - # Any changes in the working tree? (modified, staged, or untracked) - if [[ -z "$(git status --porcelain)" ]]; then - info "Working tree is clean — pulling latest changes." - if git pull --ff-only; then - ok "Already in sync." + local stashed=0 conflict_stash="" + if [[ -n "$(git status --porcelain)" ]]; then + info "Local changes; stashing them across the pull." + if git stash push --include-untracked -m "panama-update-$(date +%s)" >/dev/null; then + stashed=1 else - err "git pull failed." + err "Could not stash local changes, so the pull would overwrite them." exit 1 fi + fi + + # --ff-only on purpose. A diverged branch is something to resolve + # deliberately, not something an update command should merge on your behalf. + # It is a warning rather than an error: the stages below are still worth + # running against whatever is checked out. + if git rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1; then + info "Pulling..." + if git pull --ff-only; then + ok "Checkout is current." + else + warn "Could not fast-forward — continuing with what is checked out." + fi + else + warn "No upstream configured for this branch; nothing to pull." + fi + + if (( stashed )); then + if git stash pop >/dev/null 2>&1; then + ok "Local changes restored." + else + # A conflicted pop keeps the stash entry -- git says so itself, and the + # contract proves it -- so resetting here loses nothing. The work stays + # in the stash, where nothing is reading it, instead of in your live + # config as merge markers. + git reset --hard HEAD >/dev/null 2>&1 + conflict_stash="$(git stash list --format='%gd: %gs' 2>/dev/null | head -1)" + warn "Your local changes conflict with what was pulled; they stay stashed." + fi + fi + + local installer="$PANAMA_DIR/install" + if [[ ! -x "$installer" ]]; then + err "The installer is missing from $installer" + exit 1 + fi + + local rc=0 + "$installer" --upgrade "$@" || rc=$? + + # Repeated at the very end rather than only where it happened. A warning + # printed before twenty minutes of dnf output is a warning nobody read. + if [[ -n "$conflict_stash" ]]; then + echo + warn "Your local changes were NOT restored — they conflicted with the pull." + printf ' They are safe at %s%s%s\n' "$BOLD" "$conflict_stash" "$RESET" + printf ' Restore them with: %sgit stash pop%s\n' "$BOLD" "$RESET" + fi + + return $rc +} + +# ---------------------------------------------------------------------------- +# Command: sync +# ---------------------------------------------------------------------------- +# +# The other half of what `update` used to mean: commit and push MY EDITS. +# Panama is a working tree people edit in place -- every dotfile is a symlink +# into it -- so "I changed something, put it upstream" is a daily action and +# deserves its own verb rather than sharing one with "update my machine". +# +# The commit happens BEFORE anything is fetched, which is why there is no stash +# in here. By the time upstream is consulted the work is a commit, so a moved +# upstream is a rebase over committed history -- recoverable, ordinary, and +# nothing like a stash pop conflicting into a live config. +cmd_sync() { + require_git_repo + cd "$PANAMA_DIR" + + info "Panama repo: ${BOLD}${PANAMA_DIR}${RESET}" + + if [[ -z "$(git status --porcelain)" ]]; then + ok "Nothing to commit — the working tree is clean." + info "To update this machine, run: ${BOLD}${PROGRAM} update${RESET}" return fi - # Show what changed header "Changed files" git -c color.status=always status --short @@ -154,7 +259,6 @@ cmd_update() { return fi - # Commit message local msg printf '%s?%s Commit message: ' "${CYAN}${BOLD}" "$RESET" read -r msg || true @@ -163,46 +267,35 @@ cmd_update() { warn "No message given — using: ${BOLD}${msg}${RESET}" fi - # Is the local branch up to date with its upstream? - info "Checking whether the repo is up to date..." - if git rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1; then - git fetch --quiet - local local_rev remote_rev base_rev - local_rev=$(git rev-parse @) - remote_rev=$(git rev-parse '@{u}') - base_rev=$(git merge-base @ '@{u}') - - if [[ "$local_rev" == "$remote_rev" ]]; then - ok "Repo is up to date." - elif [[ "$local_rev" == "$base_rev" ]]; then - warn "Repo is behind upstream — stashing, pulling, then re-applying." - info "Stashing local changes..." - git stash push --include-untracked -m "panama-update-$(date +%s)" >/dev/null - - if ! git pull --ff-only; then - err "git pull failed — restoring your changes." - git stash pop || true - exit 1 - fi - - info "Re-applying stashed changes..." - if ! git stash pop; then - err "Conflict while re-applying changes. Resolve it, then commit manually." - exit 1 - fi - else - warn "Local branch has diverged from upstream — committing locally only." - fi - else - warn "No upstream configured for this branch — committing locally only." - fi - - # Commit everything info "Committing changes..." git add -A git commit -m "$msg" ok "Committed: ${BOLD}${msg}${RESET}" + if ! git rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1; then + warn "No upstream configured for this branch — committed locally only." + return + fi + + # Only now, with the work safely committed, is it worth looking upstream. + info "Checking whether upstream has moved..." + git fetch --quiet + local remote_rev base_rev + remote_rev=$(git rev-parse '@{u}') + base_rev=$(git merge-base @ '@{u}') + + if [[ "$base_rev" != "$remote_rev" ]]; then + warn "Upstream has moved — rebasing your commit onto it." + if ! git pull --rebase; then + err "The rebase stopped on a conflict." + err "Resolve it, then: git rebase --continue" + exit 1 + fi + ok "Rebased onto upstream." + else + ok "Upstream has not moved." + fi + echo if confirm "Push the changes now?"; then info "Pushing..." @@ -539,6 +632,7 @@ main() { local cmd="${1:-}" case "$cmd" in update) shift; cmd_update "$@" ;; + sync) shift; cmd_sync "$@" ;; edit) shift; cmd_edit "$@" ;; doctor) shift; cmd_doctor "$@" ;; test) shift; cmd_test "$@" ;; diff --git a/config/dot/quickshell/manual/05-making-it-yours.md b/config/dot/quickshell/manual/05-making-it-yours.md index 3470b75..8cc58c4 100644 --- a/config/dot/quickshell/manual/05-making-it-yours.md +++ b/config/dot/quickshell/manual/05-making-it-yours.md @@ -34,10 +34,10 @@ screen and the system monitor at once — not just the shell's own windows. ### Build your own -The **Theme editor** tab is four colours: primary, secondary, background and -foreground. Each has a swatch, a hex field you can type into, a colour wheel +The **Theme editor** tab is four colors: primary, secondary, background and +foreground. Each has a swatch, a hex field you can type into, a color wheel and an eyedropper for sampling anything on screen. Background and foreground -are not single colours — the panels, popovers, dividers and dimmed text are all +are not single colors — the panels, popovers, dividers and dimmed text are all mixed from them, so moving the background moves the whole family with it. Under that, a saturation slider for the whole palette at once, a fine-tune for @@ -67,7 +67,7 @@ titlebar can be turned off entirely, which leaves the Settings window bare: ## Shell Everything Panama draws on the screen has a tab under **Shell**: the bar, the -dock, Control Center, tiling and workspaces. Appearance decides the colours; +dock, Control Center, tiling and workspaces. Appearance decides the colors; this decides what is there at all. ### The bar @@ -125,14 +125,14 @@ behaviour, how long a focus session runs, and your saved projects. ## Carrying settings between machines -System › **Sync & Backup**. Export writes your settings to a file; importing +System › **[Sync & Backup](panama://settings/sync)**. Export writes your settings to a file; importing one shows you exactly what would change before anything does, and you decide then. Below it, **Settings backups** keeps dated copies you can restore from — these are your settings, not btrfs Snapshots, which is a different tab and covers the whole filesystem. **Restore defaults** is at the bottom: it resets -appearance, dock, clock, focus and display policy and clears your Home -accessory arrangement, and leaves your pinned applications, files and paired -devices alone. +appearance, the dock and the applications pinned to it, clock, focus and +display policy, removes any themes you saved, and clears your Home accessory +arrangement. Your files, paired devices and settings backups are left alone. ## Applications @@ -158,4 +158,4 @@ without editing a file the repository will update. The shell is QML under `~/.config/quickshell`. Both directories are symlinks into the Panama repository, so an edit is a change to your checkout and -`panama update` will offer to commit it. +`panama sync` will offer to commit it. diff --git a/docs/superpowers/specs/2026-08-23-panama-update-command-design.md b/docs/superpowers/specs/2026-08-23-panama-update-command-design.md new file mode 100644 index 0000000..d40abd8 --- /dev/null +++ b/docs/superpowers/specs/2026-08-23-panama-update-command-design.md @@ -0,0 +1,261 @@ +# Panama: one command to update a machine + +## Why now + +Updating a machine currently takes three commands, and one of them asks the +full first-install questionnaire every time: + +```sh +panama update # actually: commit & push my edits, or pull if clean +panama migrate # the repairs +panama upgrade # ./install, interview and all +``` + +Nobody will do that routinely, which means machines drift. The interview in +particular is pure ceremony on an already-configured machine: it asks about +NVIDIA, Secure Boot, git identity and extras categories, none of which have +changed since the machine was built. + +## What is broken + +**1. The interview runs on every upgrade, and nothing needs it.** + +Five of the seven stages read no interview answer at all: + +| Stage | Reads from the interview | +|---|---| +| `install-packages` | `PANAMA_EXTRAS` only, for the *optional* categories. Empty means base lists still install. | +| `link-dotfiles` | nothing | +| `link-user` | `PANAMA_USER_CONTENT`, and falls back to the decision recorded at `$XDG_STATE_HOME/panama/user-content` | +| `change-settings` | nothing | +| `link-vicinae-scripts` | nothing | +| `setup-identity` | git name/email/editor, gh login, ssh key — genuinely first-run | +| `install-hardware` | NVIDIA, MOK hash, debloat, firmware — genuinely first-run | + +`link-user` already anticipates this in its own comment: *"Running this stage by +hand outside an install honours the recorded answer instead, so `panama upgrade` +on an already-configured machine does not need re-asking."* The capability is +there; `install:91` just calls the interview unconditionally with no way past it. + +**2. `panama update` is named for the wrong job.** + +It means "review, commit & sync local changes" — a git workflow command. README +and `config/dot/quickshell/manual/05-making-it-yours.md` both document it that +way. The command a person reaches for to update their machine is therefore the +one command that does not. + +**3. Most updates need no stages at all, and there is no way to tell.** + +Dotfiles are symlinks into the checkout, so an edit to any existing +`config/dot/**` file is live the instant `git pull` returns. A pull only needs +stages when it brings something structurally new: a new `config/dot//` +directory, anything under `config/copy/`, a new package, or a gsettings change. +Today the only way to be sure is to run everything. + +## Decisions + +1. **`panama update` becomes the machine-update command.** The git workflow + moves to `panama sync`, unchanged in intent. One verb per job; nothing + branches on state the user cannot see. +2. **`install` gains `--upgrade`**, which skips the interview and trims `STAGES` + to the five that need no answers. One stage list in the repository, so the + two paths cannot drift. +3. **`install-packages` runs when a content hash of `setup/packages/**` differs + from the recorded one.** Not a git range: Panama is developed in place, and a + hand-edited uncommitted package list must still trigger an install. +4. **A dirty tree is stashed, pulled over, and popped.** The machine update is + never blocked by uncommitted work. +5. **A conflicted pop resets the tree, keeps the stash, warns, and continues.** + Conflict markers must never reach a live config; the update proceeds against + the clean upstream checkout. +6. **The migrate notification is untouched.** It says "repairs are waiting" and + it will keep doing exactly that. +7. **`panama sync` commits before pulling**, so it needs no stash at all. + +## Design + +### Command surface + +| Command | Job | +|---|---| +| `panama update` | Bring **this machine** up to date. The routine command. | +| `panama sync` | Commit & push **my edits**. Today's `cmd_update`, renamed and simplified. | +| `panama upgrade` | Full `./install`, interview included. New machine, or to change an answer. | +| `panama migrate` | Repairs only. Unchanged; still what the login notification runs. | + +### `panama update` + +``` +1. Record pre-pull HEAD. +2. Dirty tree? git stash push --include-untracked -m "panama-update-" +3. git pull --ff-only (failure is reported, never fatal) +4. Stashed? git stash pop + on conflict: git reset --hard HEAD + leave the stash in place + record a warning for the summary +5. exec install --upgrade +``` + +Steps 1–4 are the only new logic in `panama update`; the packages gate below is +the only new logic in `install`. Everything after step 5 is `install`'s existing +tail. + +The pull is `--ff-only`. A diverged branch is a thing to resolve deliberately, +not something an update command should paper over; it reports and continues to +the stages, because the machine should still be brought in line with what is +checked out. + +### `install --upgrade` + +`install` has no argument parsing today, so this adds the first. `cmd_upgrade` +already forwards `"$@"`, so `panama upgrade --upgrade` would work but is +pointless and undocumented. + +`--upgrade` changes exactly four things: + +- The interview block (`install:91-98`) is skipped. No `PANAMA_ANSWERS` file is + created, so every `PANAMA_*` answer is unset and each stage takes its + documented empty-answer path. +- `hostnamectl` is skipped — there is no answer to apply. +- `STAGES` drops `setup-identity` and `install-hardware`. +- The closing message says the machine was updated rather than *"Panama + installed. Log out and choose the Hyprland session to start it."* + +Everything else is inherited unchanged and deliberately so: the `sudo -v` +keepalive (`change-settings` and `install-packages` both need root), the +per-stage failure collection, the migrations block, `panama-doctor --summary`, +the `post-upgrade` hook, and the exit code. + +One correction to the migrations block is required. It currently baselines when +the marker directory is absent, on the reasoning that a machine with no markers +was just built from this checkout. That inference is only sound during a real +install. Under `--upgrade` it must always take `run`; the migrations are all +self-guarding and a no-op on a machine that does not need them. + +### The packages hash + +```sh +STATE="${XDG_STATE_HOME:-$HOME/.local/state}/panama" +hash_packages() { + find "$PANAMA_PATH/setup/packages" -maxdepth 1 -type f -exec sha256sum {} + \ + | sort | sha256sum | cut -d' ' -f1 +} +``` + +`install-packages` is skipped when the hash matches `$STATE/packages-hash`. + +`-maxdepth 1` is deliberate and excludes `setup/packages/extras/`. An +answer-free run has an empty `PANAMA_EXTRAS` and installs no optional category, +so hashing those files would flip the hash, run the stage, install nothing extra, +and record the new hash as though it had. **Optional categories cannot be +re-applied by `panama update` at all**, because which ones this machine chose is +never recorded — the interview's answers are transient by design. Adding to a +category you already have is `panama apps`, which is what it is for. This is a +known limitation, not an oversight. + +The hash is written **only after the stage succeeds**, mirroring the rule +`panama-migrate` already documents for its markers: a step that did not complete +has not happened, and recording it as done hides it forever. + +Two consequences worth stating: a full `./install` always runs the stage and +writes the hash regardless of any recorded value, and the *first* `panama update` +after this ships will run `install-packages` once, because no hash exists yet. +Both are correct. + +`panama update --packages` forces the stage regardless of the hash, forwarding +`--packages` to `install --upgrade`. This is the one flag worth adding, because +the hash cannot see a package that dnf removed behind Panama's back. + +### `panama sync` + +Today's `cmd_update` with the stash dance removed. Because the changes are +committed before the pull, there is nothing to stash: + +``` +1. Clean tree? Say so and stop. Pulling is `panama update`'s job now. +2. Show status + diff, including untracked files as new-file diffs. +3. Confirm, read a commit message, git add -A && git commit. +4. Behind upstream? git pull --rebase +5. Offer to push. +``` + +A rebase conflict lands on committed work, which is an ordinary and recoverable +place to be, rather than on a stash. + +### Conflict handling + +Verified against a throwaway repository rather than assumed: + +- A conflicted `git stash pop` **keeps** the stash entry. +- Conflict markers are written into the working file. On this repository that + means directly into a live `~/.config` path via the symlink — a broken `.qml` + can cost the running shell. +- `git reset --hard HEAD` clears the markers, and `stash@{0}` survives it. + +So on conflict: reset, keep the stash, and report it in the **final summary** +rather than only in scrollback, where twenty minutes of dnf output will bury it. + +``` +! Your local changes conflicted with what was pulled. + They are safe at stash@{0}: panama-update-1787680000 + Restore with: git stash pop +``` + +## What does not change + +- `bin/panama-migrate` — no change of any kind. +- `bin/panama-migrate-notify` — still runs `panama-migrate run` in a terminal. +- The seven stage scripts — none of them are edited. This works precisely + because they already handle absent answers. +- `panama upgrade` — same behaviour, same interview. + +## Files touched + +| File | Change | +|---|---| +| `install` | Argument parsing; `--upgrade` skips interview + hostname, trims `STAGES`, forces migrate `run`, alters the closing message. Packages-hash gate around `install-packages`. | +| `bin/panama` | `cmd_update` rewritten; `cmd_sync` added (the old body, minus the stash); dispatcher, usage block and header comment updated. | +| `README.md` | The upgrade-path paragraph and the command table. | +| `config/dot/quickshell/manual/05-making-it-yours.md` | The line telling the reader `panama update` will offer to commit. | +| `tests/setup/` | New contracts, below. | + +## Tests + +Four contracts, each pinning a decision that would otherwise rot silently: + +1. **`install --upgrade` never invokes the interview.** The regression that + started this. Assert `setup/scripts/interview` is not executed. +2. **`--upgrade` runs exactly the five answer-free stages**, and never + `setup-identity` or `install-hardware`. +3. **The packages hash gates the stage**, and is not written when the stage + exits non-zero. Note this is a narrow guarantee: `install-packages` logs and + steps over individual package failures internally and still exits 0, so a + single missing package does not hold the hash back. Only a stage-level + failure does. +4. **A conflicted pop leaves no conflict markers in the tree and keeps the + stash.** Runs against a temporary repository, as the verification above did. + +Contract 2 is the one that matters most over time: it fails the moment a stage +is added to `STAGES` without a decision about which path owns it. + +## Verification + +On this machine, after implementation: + +```sh +panama test update # the new contracts +panama update # expect: no questions, one sudo prompt, packages skipped +panama update # expect: idempotent, still no questions +``` + +The second run is the real check. Everything must be a no-op, and +`install-packages` must be skipped on both. + +## Out of scope + +- Any change to how migrations work. They are correct. +- A GUI entry point for updates. The notification covers repairs; a full update + is a terminal action because it needs a password and prints a summary worth + reading. +- Rewriting the seven stage scripts. Their empty-answer behaviour is what makes + this design a flag rather than a refactor. diff --git a/install b/install index d83ee64..a667eb4 100755 --- a/install +++ b/install @@ -2,12 +2,103 @@ # Panama's installer. Safe to re-run: every stage is idempotent, and this is # also the upgrade path. +# +# ./install A machine being built. Asks the interview, runs +# every stage, enrolls hardware. +# ./install --upgrade A machine that already exists. Asks nothing. +# +# Two entry points, one stage list, deliberately in one file. `panama update` +# passes --upgrade; if the upgrade path owned a second copy of STAGES the two +# would drift the first time somebody added a stage to one of them, and the +# symptom would be a stage that silently never runs. Keeping the lists together +# means the decision about which path owns a new stage is made in view of the +# other one. +# +# What --upgrade changes, and nothing else: +# +# * The interview is skipped, so every PANAMA_* answer is unset and each +# stage takes its documented empty-answer path. Five of the seven need no +# answer at all; link-user falls back to the decision it recorded. +# * setup-identity and install-hardware are dropped. They exist only to +# consume interview answers -- git identity, NVIDIA, Secure Boot, firmware +# -- and every one of those is a first-run decision. +# * install-packages runs only when the package lists actually changed. +# * Migrations always run rather than baseline. See the migrations block. +# +# Everything else is shared on purpose: the sudo keepalive, the per-stage +# failure collection, migrations, the health summary and the post-upgrade hook. set -uo pipefail PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}" + +UPGRADE=0 +FORCE_PACKAGES=0 +for arg in "$@"; do + case "$arg" in + --upgrade) UPGRADE=1 ;; + --packages) FORCE_PACKAGES=1 ;; + -h|--help) + cat <<'USAGE' +usage: install [--upgrade] [--packages] + + (no arguments) Build this machine. Asks the interview, runs every stage. + --upgrade Update a machine that already exists. Asks nothing, and + skips setup-identity and install-hardware. + --packages Run install-packages even when the lists are unchanged. + Only meaningful with --upgrade; a full install always runs it. +USAGE + exit 0 ;; + *) + printf 'install: unknown argument: %s\n' "$arg" >&2 + printf "Run './install --help' to see what it takes.\n" >&2 + exit 2 ;; + esac +done + source "$PANAMA_PATH/bin/ascii" +# ── Have the package lists changed? ────────────────────────────────────────── +# +# install-packages is the slow stage -- a dnf metadata refresh, a Flathub +# round-trip, and a transaction that resolves to "nothing to do" almost every +# time. On an upgrade it is worth running only when the lists it reads actually +# changed, so this hashes them and remembers the result. +# +# A content hash rather than a git range, because Panama is developed in place: +# a package added to a list and not yet committed must still install. A range +# check would see nothing, and the package would arrive whenever the commit +# happened to be pulled somewhere else. +# +# -maxdepth 1 excludes setup/packages/extras/. An answer-free run has an empty +# PANAMA_EXTRAS and installs no optional category, so hashing those files would +# flip the hash, run the stage, install nothing, and record the new hash as +# though it had. Optional categories cannot be re-applied by an upgrade at all +# -- which ones this machine chose is nowhere on disk, because the interview's +# answers are deliberately transient -- and `panama apps` is the tool for that. +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/panama" +PACKAGES_HASH="$STATE_DIR/packages-hash" + +hash_packages() { + find "$PANAMA_PATH/setup/packages" -maxdepth 1 -type f -exec sha256sum {} + \ + | sort | sha256sum | cut -d' ' -f1 +} + +packages_needed() { + (( FORCE_PACKAGES )) && return 0 + (( UPGRADE )) || return 0 + [[ -r "$PACKAGES_HASH" ]] || return 0 + [[ "$(hash_packages)" != "$(cat "$PACKAGES_HASH")" ]] +} + +# Written only after the stage succeeds, mirroring the rule panama-migrate +# documents for its markers: a step that did not complete has not happened, and +# recording it as done hides it forever. +record_packages_hash() { + mkdir -p "$STATE_DIR" + hash_packages >"$PACKAGES_HASH" +} + # ── The interview ──────────────────────────────────────────────────────────── # # Everything Panama needs to be told is asked here, before a single package is @@ -22,17 +113,22 @@ source "$PANAMA_PATH/bin/ascii" # BEFORE install-packages runs, and a missing probe tool degrades the answer # silently to "no" -- which for Secure Boot once meant installing a driver # that could never load. Workstation ships all four; a minimal base does not. -bootstrap=() -command -v gum >/dev/null 2>&1 || bootstrap+=(gum) -command -v lspci >/dev/null 2>&1 || bootstrap+=(pciutils) -command -v mokutil >/dev/null 2>&1 || bootstrap+=(mokutil) -command -v fwupdmgr >/dev/null 2>&1 || bootstrap+=(fwupd) -if (( ${#bootstrap[@]} > 0 )); then - echo "Installing what the setup questions are built on: ${bootstrap[*]}" - sudo dnf install -y "${bootstrap[@]}" >/dev/null || { - echo "Could not install ${bootstrap[*]}, so the setup questions cannot be asked." >&2 - exit 1 - } +# Gated exactly like the interview itself: under --upgrade no questions are +# asked, so nothing here is used, and a machine that cannot install gum must +# not have that stop an upgrade that never needed it. +if (( ! UPGRADE )); then + bootstrap=() + command -v gum >/dev/null 2>&1 || bootstrap+=(gum) + command -v lspci >/dev/null 2>&1 || bootstrap+=(pciutils) + command -v mokutil >/dev/null 2>&1 || bootstrap+=(mokutil) + command -v fwupdmgr >/dev/null 2>&1 || bootstrap+=(fwupd) + if (( ${#bootstrap[@]} > 0 )); then + echo "Installing what the setup questions are built on: ${bootstrap[*]}" + sudo dnf install -y "${bootstrap[@]}" >/dev/null || { + echo "Could not install ${bootstrap[*]}, so the setup questions cannot be asked." >&2 + exit 1 + } + fi fi # ── Keep the machine awake for the duration ────────────────────────────────── @@ -85,17 +181,23 @@ gsettings set org.gnome.desktop.session idle-delay 0 2>/dev/null || true # nothing personal reaches a durable path, which is what keeps this repository # something somebody else could clone. Created here rather than earlier so the # trap that deletes it is already armed before the file exists. -PANAMA_ANSWERS="$(mktemp -t panama-answers.XXXXXX)" -export PANAMA_ANSWERS +# +# Skipped entirely under --upgrade. Nothing is exported, so every answer below +# is unset and each stage takes the empty-answer path it already documents -- +# which is why this is a flag rather than a rewrite of seven stage scripts. +if (( ! UPGRADE )); then + PANAMA_ANSWERS="$(mktemp -t panama-answers.XXXXXX)" + export PANAMA_ANSWERS -if ! "$PANAMA_PATH/setup/scripts/interview"; then - exit 1 + if ! "$PANAMA_PATH/setup/scripts/interview"; then + exit 1 + fi + # shellcheck source=/dev/null + source "$PANAMA_ANSWERS" + export PANAMA_HOSTNAME PANAMA_GIT_NAME PANAMA_GIT_EMAIL PANAMA_GIT_EDITOR \ + PANAMA_GH_LOGIN PANAMA_SSH_KEY PANAMA_NVIDIA PANAMA_MOK_HASH \ + PANAMA_DEBLOAT PANAMA_FIRMWARE PANAMA_EXTRAS PANAMA_USER_CONTENT fi -# shellcheck source=/dev/null -source "$PANAMA_ANSWERS" -export PANAMA_HOSTNAME PANAMA_GIT_NAME PANAMA_GIT_EMAIL PANAMA_GIT_EDITOR \ - PANAMA_GH_LOGIN PANAMA_SSH_KEY PANAMA_NVIDIA PANAMA_MOK_HASH \ - PANAMA_DEBLOAT PANAMA_FIRMWARE PANAMA_EXTRAS PANAMA_USER_CONTENT # One password, before anything long runs, and then never again. The stages # call sudo dozens of times across twenty-plus minutes, and the timestamp @@ -104,7 +206,11 @@ export PANAMA_HOSTNAME PANAMA_GIT_NAME PANAMA_GIT_EMAIL PANAMA_GIT_EDITOR \ # there to answer. The refresher holds the timestamp open for exactly as long # as this script lives; cleanup() kills it on every exit path, so nothing # outlives the install with ambient credentials. -echo "Panama needs administrator rights for the rest of the run." +if (( UPGRADE )); then + echo "Panama needs administrator rights to apply system settings and packages." +else + echo "Panama needs administrator rights for the rest of the run." +fi sudo -v || exit 1 ( while kill -0 "$$" 2>/dev/null; do sudo -n true 2>/dev/null || true; sleep 60; done ) & SUDO_KEEPALIVE=$! @@ -117,14 +223,37 @@ if [[ -n "${PANAMA_HOSTNAME:-}" ]]; then fi STAGES=(install-packages link-dotfiles link-user change-settings link-vicinae-scripts setup-identity install-hardware) + +# The two an upgrade drops. Both exist only to act on interview answers, and +# both are first-run decisions: who you are and what hardware this is. Filtered +# by name rather than by position so reordering STAGES cannot silently change +# which stages an upgrade runs. +if (( UPGRADE )); then + upgrade_stages=() + for stage in "${STAGES[@]}"; do + case "$stage" in + setup-identity|install-hardware) continue ;; + esac + upgrade_stages+=("$stage") + done + STAGES=("${upgrade_stages[@]}") +fi + failed=() for stage in "${STAGES[@]}"; do script="$PANAMA_PATH/setup/scripts/$stage" [[ -x "$script" ]] || continue printf '\n=== %s ===\n' "$stage" + if [[ "$stage" == install-packages ]] && ! packages_needed; then + echo "The package lists have not changed since the last run; skipping." + echo "Run with --packages to install them anyway." + continue + fi if ! "$script"; then failed+=("$stage") printf '!!! %s failed\n' "$stage" >&2 + elif [[ "$stage" == install-packages ]]; then + record_packages_hash fi done @@ -140,10 +269,17 @@ done # already true of it -- they are marked applied without running, exactly as # Migrations.qml stamps a pre-versioning settings file at its baseline rather # than replaying upgrades it never needed. Otherwise the pending ones run. +# +# That inference is only sound during a real install. Under --upgrade the +# machine demonstrably existed before this run, so an absent marker directory +# means it predates migrations entirely -- exactly the machine the repairs were +# written for -- and baselining would skip every one of them forever. Every +# migration is self-guarding and a no-op where it does not apply, so running +# them is the safe direction. migrate="$PANAMA_PATH/bin/panama-migrate" if [[ -x "$migrate" ]]; then printf '\n=== migrations ===\n' - if [[ -d "${XDG_STATE_HOME:-$HOME/.local/state}/panama/migrations" ]]; then + if (( UPGRADE )) || [[ -d "$STATE_DIR/migrations" ]]; then "$migrate" run || failed+=(migrations) else "$migrate" --baseline || true @@ -172,10 +308,24 @@ hook="$PANAMA_PATH/bin/panama-hook" [[ -x "$hook" ]] && "$hook" post-upgrade || true printf '\n' -if (( ${#failed[@]} == 0 )); then - echo "Panama installed. Log out and choose the Hyprland session to start it." +if (( UPGRADE )); then + retry='panama update' else - printf 'Panama installed with %d failed stage(s): %s\n' "${#failed[@]}" "${failed[*]}" >&2 - printf 'Re-running ./install is safe and will retry them.\n' >&2 + retry='./install' +fi + +if (( ${#failed[@]} == 0 )); then + if (( UPGRADE )); then + echo "Panama is up to date." + else + echo "Panama installed. Log out and choose the Hyprland session to start it." + fi +else + if (( UPGRADE )); then + printf 'Panama updated with %d failed stage(s): %s\n' "${#failed[@]}" "${failed[*]}" >&2 + else + printf 'Panama installed with %d failed stage(s): %s\n' "${#failed[@]}" "${failed[*]}" >&2 + fi + printf 'Re-running %s is safe and will retry them.\n' "$retry" >&2 exit 1 fi diff --git a/setup/scripts/install-packages b/setup/scripts/install-packages index cc059b0..1a2c96f 100755 --- a/setup/scripts/install-packages +++ b/setup/scripts/install-packages @@ -232,10 +232,10 @@ if [[ -s /etc/profile.d/nvm.sh ]]; then source /etc/profile.d/nvm.sh if nvm install --lts >/dev/null 2>&1; then nvm alias default 'lts/*' >/dev/null 2>&1 || true - npm install -g pnpm >/dev/null 2>&1 || log "pnpm did not install" + npm install -g pnpm >/dev/null 2>&1 || { log "pnpm did not install"; softly_failed+=("pnpm"); } log "Node $(node --version 2>/dev/null) with pnpm $(pnpm --version 2>/dev/null)" else - log "nvm could not install Node; skipping" + log "nvm could not install Node; skipping"; softly_failed+=("Node (nvm)") fi set -u else @@ -265,7 +265,7 @@ if [[ -x "$HOME/.bun/bin/bun" ]]; then log "Bun already installed at \"$HOME/.bun/bin/bun\"" else log "Installing Bun via curl..." - curl -fsSL https://bun.sh/install | bash > /dev/null 2>&1 || log "Bun install failed; skipping" + curl -fsSL https://bun.sh/install | bash > /dev/null 2>&1 || { log "Bun install failed; skipping"; softly_failed+=("Bun"); } fi # Claude Code: Anthropic's CLI. The official installer keeps itself updated @@ -274,7 +274,7 @@ if command -v claude >/dev/null 2>&1; then log "Claude Code already installed at \"$(command -v claude)\"" else log "Installing Claude Code via the official installer..." - curl -fsSL https://claude.ai/install.sh | bash > /dev/null 2>&1 || log "Claude Code install failed; skipping" + curl -fsSL https://claude.ai/install.sh | bash > /dev/null 2>&1 || { log "Claude Code install failed; skipping"; softly_failed+=("Claude Code"); } fi # Claude Desktop: Anthropic ships macOS and Windows only, so this is a community @@ -307,7 +307,7 @@ else fi log "Installing Claude Desktop..." sudo dnf install -y claude-desktop-extra > /dev/null \ - || log "Claude Desktop install failed; skipping" + || { log "Claude Desktop install failed; skipping"; softly_failed+=("Claude Desktop"); } fi # RustDesk: remote desktop. The flatpak cannot register the root-owned system @@ -328,9 +328,9 @@ else log "Installing RustDesk from $rustdesk_url" # The RPM ships rustdesk.service already enabled, which is what provides # unattended access; Panama deliberately does not start it a second time. - sudo dnf install -y "$rustdesk_url" > /dev/null || log "RustDesk install failed; skipping" + sudo dnf install -y "$rustdesk_url" > /dev/null || { log "RustDesk install failed; skipping"; softly_failed+=("RustDesk"); } else - log "Could not resolve a RustDesk release; skipping" + log "Could not resolve a RustDesk release; skipping"; softly_failed+=("RustDesk") fi fi @@ -388,12 +388,12 @@ install_extra_category() { if [[ -n "${dnf_packages// /}" ]]; then log "Installing $name: $dnf_packages" - sudo dnf install -y $dnf_packages > /dev/null || log "Some $name packages did not install" + sudo dnf install -y $dnf_packages > /dev/null || { log "Some $name packages did not install"; softly_failed+=("$name packages"); } fi if [[ -n "${flatpak_ids// /}" ]]; then log "Installing $name flatpaks: $flatpak_ids" sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo > /dev/null - sudo flatpak install -y flathub $flatpak_ids > /dev/null || log "Some $name flatpaks did not install" + sudo flatpak install -y flathub $flatpak_ids > /dev/null || { log "Some $name flatpaks did not install"; softly_failed+=("$name flatpaks"); } fi } @@ -416,5 +416,10 @@ done if (( ${#softly_failed[@]} > 0 )); then log "Installed, but these were stepped over:" printf ' - %s\n' "${softly_failed[@]}" - log "None of them stops the desktop. Re-run this stage to try them again." + log "None of them stops the desktop, but this run is not recorded as" + log "complete, so the next 'panama update' tries them again." + # A step that did not complete has not happened. Exiting non-zero is what + # keeps ./install from stamping the packages hash over the gaps -- stamped, + # they would never be retried (the hash-skip would say nothing changed). + exit 1 fi diff --git a/tests/setup/update-command-contract b/tests/setup/update-command-contract new file mode 100755 index 0000000..4c0018d --- /dev/null +++ b/tests/setup/update-command-contract @@ -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" <>"\$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'