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:
@@ -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
|
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/`
|
`install` asks its questions first and then runs the stages in `setup/scripts/`
|
||||||
in order, without stopping again:
|
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
|
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
|
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
|
application that refuses to open twice — Slack, Thunderbird, the browser — is
|
||||||
moved into place rather than launched again. Saved layouts are listed on the
|
moved into place rather than launched again. Saved layouts are listed on
|
||||||
Desktop settings page, which is also where they are removed.
|
Shell › Workspaces in Settings, which is also where they are removed.
|
||||||
|
|
||||||
## The `panama` command
|
## The `panama` command
|
||||||
|
|
||||||
```sh
|
```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 edit # open it in Neovim
|
||||||
panama doctor # what is actually running, not what was installed
|
panama doctor # what is actually running, not what was installed
|
||||||
panama test # every contract, or a subset by pattern
|
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 apps # choose applications to install, by category
|
||||||
panama app # applications no repository carries; build one by name
|
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
|
`panama apps` is the optional-application catalog, opened after the fact. The
|
||||||
interview offers the same categories during `./install`, whole; this picks a
|
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
|
category and then the applications inside it, so a machine can acquire Slack in
|
||||||
|
|||||||
+143
-49
@@ -5,15 +5,22 @@
|
|||||||
# Author: Gabriel Brown
|
# Author: Gabriel Brown
|
||||||
#
|
#
|
||||||
# Commands:
|
# 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
|
# edit Open the Panama repo in Neovim
|
||||||
# doctor Report what is actually running on this machine
|
# doctor Report what is actually running on this machine
|
||||||
# test Run every contract under tests/
|
# 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
|
# apps Choose applications to install, by category
|
||||||
# app Build and install an application that no repository packages
|
# app Build and install an application that no repository packages
|
||||||
# help Show this help
|
# 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_<name> functions and
|
# Designed to grow: add new subcommands as cmd_<name> functions and
|
||||||
# register them in the dispatcher / usage block below.
|
# register them in the dispatcher / usage block below.
|
||||||
|
|
||||||
@@ -64,15 +71,20 @@ ${BOLD}Usage:${RESET}
|
|||||||
$PROGRAM <command> [options]
|
$PROGRAM <command> [options]
|
||||||
|
|
||||||
${BOLD}Commands:${RESET}
|
${BOLD}Commands:${RESET}
|
||||||
${GREEN}update${RESET} Review, commit & sync local changes. If the working tree is
|
${GREEN}update${RESET} Bring this machine up to date. Pulls, then runs the stages
|
||||||
clean it simply runs 'git pull'.
|
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}edit${RESET} Open the Panama repo in Neovim.
|
||||||
${GREEN}doctor${RESET} Report what is actually running on this machine, rather
|
${GREEN}doctor${RESET} Report what is actually running on this machine, rather
|
||||||
than what was installed. Takes --summary for one line per check.
|
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
|
${GREEN}test${RESET} Run every contract under tests/. Give it a pattern to run
|
||||||
a subset: 'panama test dock' runs the ones matching 'dock'.
|
a subset: 'panama test dock' runs the ones matching 'dock'.
|
||||||
${GREEN}upgrade${RESET} Re-run ./install from anywhere. Safe: every stage is
|
${GREEN}upgrade${RESET} Re-run ./install from anywhere, interview and all. For a new
|
||||||
idempotent and this is the documented upgrade path.
|
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
|
${GREEN}migrate${RESET} Apply repairs this machine has not had yet. The half of an
|
||||||
upgrade that ./install cannot do, because installing only ever
|
upgrade that ./install cannot do, because installing only ever
|
||||||
adds. Safe to re-run; nothing is applied twice.
|
adds. Safe to re-run; nothing is applied twice.
|
||||||
@@ -89,6 +101,8 @@ ${BOLD}Options:${RESET}
|
|||||||
|
|
||||||
${BOLD}Examples:${RESET}
|
${BOLD}Examples:${RESET}
|
||||||
$PROGRAM update
|
$PROGRAM update
|
||||||
|
$PROGRAM update --packages
|
||||||
|
$PROGRAM sync
|
||||||
$PROGRAM edit
|
$PROGRAM edit
|
||||||
$PROGRAM doctor --summary
|
$PROGRAM doctor --summary
|
||||||
$PROGRAM test dock
|
$PROGRAM test dock
|
||||||
@@ -112,25 +126,116 @@ require_git_repo() {
|
|||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
# Command: update
|
# 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() {
|
cmd_update() {
|
||||||
require_git_repo
|
require_git_repo
|
||||||
cd "$PANAMA_DIR"
|
cd "$PANAMA_DIR"
|
||||||
|
|
||||||
info "Panama repo: ${BOLD}${PANAMA_DIR}${RESET}"
|
info "Panama repo: ${BOLD}${PANAMA_DIR}${RESET}"
|
||||||
|
|
||||||
# Any changes in the working tree? (modified, staged, or untracked)
|
local stashed=0 conflict_stash=""
|
||||||
if [[ -z "$(git status --porcelain)" ]]; then
|
if [[ -n "$(git status --porcelain)" ]]; then
|
||||||
info "Working tree is clean — pulling latest changes."
|
info "Local changes; stashing them across the pull."
|
||||||
if git pull --ff-only; then
|
if git stash push --include-untracked -m "panama-update-$(date +%s)" >/dev/null; then
|
||||||
ok "Already in sync."
|
stashed=1
|
||||||
else
|
else
|
||||||
err "git pull failed."
|
err "Could not stash local changes, so the pull would overwrite them."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Show what changed
|
|
||||||
header "Changed files"
|
header "Changed files"
|
||||||
git -c color.status=always status --short
|
git -c color.status=always status --short
|
||||||
|
|
||||||
@@ -154,7 +259,6 @@ cmd_update() {
|
|||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Commit message
|
|
||||||
local msg
|
local msg
|
||||||
printf '%s?%s Commit message: ' "${CYAN}${BOLD}" "$RESET"
|
printf '%s?%s Commit message: ' "${CYAN}${BOLD}" "$RESET"
|
||||||
read -r msg || true
|
read -r msg || true
|
||||||
@@ -163,46 +267,35 @@ cmd_update() {
|
|||||||
warn "No message given — using: ${BOLD}${msg}${RESET}"
|
warn "No message given — using: ${BOLD}${msg}${RESET}"
|
||||||
fi
|
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..."
|
info "Committing changes..."
|
||||||
git add -A
|
git add -A
|
||||||
git commit -m "$msg"
|
git commit -m "$msg"
|
||||||
ok "Committed: ${BOLD}${msg}${RESET}"
|
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
|
echo
|
||||||
if confirm "Push the changes now?"; then
|
if confirm "Push the changes now?"; then
|
||||||
info "Pushing..."
|
info "Pushing..."
|
||||||
@@ -539,6 +632,7 @@ main() {
|
|||||||
local cmd="${1:-}"
|
local cmd="${1:-}"
|
||||||
case "$cmd" in
|
case "$cmd" in
|
||||||
update) shift; cmd_update "$@" ;;
|
update) shift; cmd_update "$@" ;;
|
||||||
|
sync) shift; cmd_sync "$@" ;;
|
||||||
edit) shift; cmd_edit "$@" ;;
|
edit) shift; cmd_edit "$@" ;;
|
||||||
doctor) shift; cmd_doctor "$@" ;;
|
doctor) shift; cmd_doctor "$@" ;;
|
||||||
test) shift; cmd_test "$@" ;;
|
test) shift; cmd_test "$@" ;;
|
||||||
|
|||||||
@@ -34,10 +34,10 @@ screen and the system monitor at once — not just the shell's own windows.
|
|||||||
|
|
||||||
### Build your own
|
### Build your own
|
||||||
|
|
||||||
The **Theme editor** tab is four colours: primary, secondary, background and
|
The **Theme editor** tab is four colors: primary, secondary, background and
|
||||||
foreground. Each has a swatch, a hex field you can type into, a colour wheel
|
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
|
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.
|
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
|
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
|
## Shell
|
||||||
|
|
||||||
Everything Panama draws on the screen has a tab under **Shell**: the bar, the
|
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.
|
this decides what is there at all.
|
||||||
|
|
||||||
### The bar
|
### The bar
|
||||||
@@ -125,14 +125,14 @@ behaviour, how long a focus session runs, and your saved projects.
|
|||||||
|
|
||||||
## Carrying settings between machines
|
## 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
|
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 —
|
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
|
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
|
covers the whole filesystem. **Restore defaults** is at the bottom: it resets
|
||||||
appearance, dock, clock, focus and display policy and clears your Home
|
appearance, the dock and the applications pinned to it, clock, focus and
|
||||||
accessory arrangement, and leaves your pinned applications, files and paired
|
display policy, removes any themes you saved, and clears your Home accessory
|
||||||
devices alone.
|
arrangement. Your files, paired devices and settings backups are left alone.
|
||||||
|
|
||||||
## Applications
|
## Applications
|
||||||
|
|
||||||
@@ -158,4 +158,4 @@ without editing a file the repository will update.
|
|||||||
|
|
||||||
The shell is QML under `~/.config/quickshell`. Both directories are symlinks
|
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
|
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.
|
||||||
|
|||||||
@@ -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/<name>/`
|
||||||
|
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-<epoch>"
|
||||||
|
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.
|
||||||
@@ -2,12 +2,103 @@
|
|||||||
|
|
||||||
# Panama's installer. Safe to re-run: every stage is idempotent, and this is
|
# Panama's installer. Safe to re-run: every stage is idempotent, and this is
|
||||||
# also the upgrade path.
|
# 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
|
set -uo pipefail
|
||||||
|
|
||||||
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
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"
|
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 ────────────────────────────────────────────────────────────
|
# ── The interview ────────────────────────────────────────────────────────────
|
||||||
#
|
#
|
||||||
# Everything Panama needs to be told is asked here, before a single package is
|
# Everything Panama needs to be told is asked here, before a single package is
|
||||||
@@ -22,6 +113,10 @@ source "$PANAMA_PATH/bin/ascii"
|
|||||||
# BEFORE install-packages runs, and a missing probe tool degrades the answer
|
# BEFORE install-packages runs, and a missing probe tool degrades the answer
|
||||||
# silently to "no" -- which for Secure Boot once meant installing a driver
|
# 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.
|
# that could never load. Workstation ships all four; a minimal base does not.
|
||||||
|
# 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=()
|
bootstrap=()
|
||||||
command -v gum >/dev/null 2>&1 || bootstrap+=(gum)
|
command -v gum >/dev/null 2>&1 || bootstrap+=(gum)
|
||||||
command -v lspci >/dev/null 2>&1 || bootstrap+=(pciutils)
|
command -v lspci >/dev/null 2>&1 || bootstrap+=(pciutils)
|
||||||
@@ -34,6 +129,7 @@ if (( ${#bootstrap[@]} > 0 )); then
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Keep the machine awake for the duration ──────────────────────────────────
|
# ── Keep the machine awake for the duration ──────────────────────────────────
|
||||||
# Package installation takes long enough to hit an idle lock, and being locked
|
# Package installation takes long enough to hit an idle lock, and being locked
|
||||||
@@ -85,6 +181,11 @@ 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
|
# nothing personal reaches a durable path, which is what keeps this repository
|
||||||
# something somebody else could clone. Created here rather than earlier so the
|
# something somebody else could clone. Created here rather than earlier so the
|
||||||
# trap that deletes it is already armed before the file exists.
|
# trap that deletes it is already armed before the file exists.
|
||||||
|
#
|
||||||
|
# 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)"
|
PANAMA_ANSWERS="$(mktemp -t panama-answers.XXXXXX)"
|
||||||
export PANAMA_ANSWERS
|
export PANAMA_ANSWERS
|
||||||
|
|
||||||
@@ -96,6 +197,7 @@ source "$PANAMA_ANSWERS"
|
|||||||
export PANAMA_HOSTNAME PANAMA_GIT_NAME PANAMA_GIT_EMAIL PANAMA_GIT_EDITOR \
|
export PANAMA_HOSTNAME PANAMA_GIT_NAME PANAMA_GIT_EMAIL PANAMA_GIT_EDITOR \
|
||||||
PANAMA_GH_LOGIN PANAMA_SSH_KEY PANAMA_NVIDIA PANAMA_MOK_HASH \
|
PANAMA_GH_LOGIN PANAMA_SSH_KEY PANAMA_NVIDIA PANAMA_MOK_HASH \
|
||||||
PANAMA_DEBLOAT PANAMA_FIRMWARE PANAMA_EXTRAS PANAMA_USER_CONTENT
|
PANAMA_DEBLOAT PANAMA_FIRMWARE PANAMA_EXTRAS PANAMA_USER_CONTENT
|
||||||
|
fi
|
||||||
|
|
||||||
# One password, before anything long runs, and then never again. The stages
|
# One password, before anything long runs, and then never again. The stages
|
||||||
# call sudo dozens of times across twenty-plus minutes, and the timestamp
|
# 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
|
# 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
|
# as this script lives; cleanup() kills it on every exit path, so nothing
|
||||||
# outlives the install with ambient credentials.
|
# outlives the install with ambient credentials.
|
||||||
|
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."
|
echo "Panama needs administrator rights for the rest of the run."
|
||||||
|
fi
|
||||||
sudo -v || exit 1
|
sudo -v || exit 1
|
||||||
( while kill -0 "$$" 2>/dev/null; do sudo -n true 2>/dev/null || true; sleep 60; done ) &
|
( while kill -0 "$$" 2>/dev/null; do sudo -n true 2>/dev/null || true; sleep 60; done ) &
|
||||||
SUDO_KEEPALIVE=$!
|
SUDO_KEEPALIVE=$!
|
||||||
@@ -117,14 +223,37 @@ if [[ -n "${PANAMA_HOSTNAME:-}" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
STAGES=(install-packages link-dotfiles link-user change-settings link-vicinae-scripts setup-identity install-hardware)
|
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=()
|
failed=()
|
||||||
for stage in "${STAGES[@]}"; do
|
for stage in "${STAGES[@]}"; do
|
||||||
script="$PANAMA_PATH/setup/scripts/$stage"
|
script="$PANAMA_PATH/setup/scripts/$stage"
|
||||||
[[ -x "$script" ]] || continue
|
[[ -x "$script" ]] || continue
|
||||||
printf '\n=== %s ===\n' "$stage"
|
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
|
if ! "$script"; then
|
||||||
failed+=("$stage")
|
failed+=("$stage")
|
||||||
printf '!!! %s failed\n' "$stage" >&2
|
printf '!!! %s failed\n' "$stage" >&2
|
||||||
|
elif [[ "$stage" == install-packages ]]; then
|
||||||
|
record_packages_hash
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -140,10 +269,17 @@ done
|
|||||||
# already true of it -- they are marked applied without running, exactly as
|
# already true of it -- they are marked applied without running, exactly as
|
||||||
# Migrations.qml stamps a pre-versioning settings file at its baseline rather
|
# Migrations.qml stamps a pre-versioning settings file at its baseline rather
|
||||||
# than replaying upgrades it never needed. Otherwise the pending ones run.
|
# 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"
|
migrate="$PANAMA_PATH/bin/panama-migrate"
|
||||||
if [[ -x "$migrate" ]]; then
|
if [[ -x "$migrate" ]]; then
|
||||||
printf '\n=== migrations ===\n'
|
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)
|
"$migrate" run || failed+=(migrations)
|
||||||
else
|
else
|
||||||
"$migrate" --baseline || true
|
"$migrate" --baseline || true
|
||||||
@@ -172,10 +308,24 @@ hook="$PANAMA_PATH/bin/panama-hook"
|
|||||||
[[ -x "$hook" ]] && "$hook" post-upgrade || true
|
[[ -x "$hook" ]] && "$hook" post-upgrade || true
|
||||||
|
|
||||||
printf '\n'
|
printf '\n'
|
||||||
|
if (( UPGRADE )); then
|
||||||
|
retry='panama update'
|
||||||
|
else
|
||||||
|
retry='./install'
|
||||||
|
fi
|
||||||
|
|
||||||
if (( ${#failed[@]} == 0 )); then
|
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."
|
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
|
else
|
||||||
printf 'Panama installed with %d failed stage(s): %s\n' "${#failed[@]}" "${failed[*]}" >&2
|
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
|
fi
|
||||||
|
printf 'Re-running %s is safe and will retry them.\n' "$retry" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -232,10 +232,10 @@ if [[ -s /etc/profile.d/nvm.sh ]]; then
|
|||||||
source /etc/profile.d/nvm.sh
|
source /etc/profile.d/nvm.sh
|
||||||
if nvm install --lts >/dev/null 2>&1; then
|
if nvm install --lts >/dev/null 2>&1; then
|
||||||
nvm alias default 'lts/*' >/dev/null 2>&1 || true
|
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)"
|
log "Node $(node --version 2>/dev/null) with pnpm $(pnpm --version 2>/dev/null)"
|
||||||
else
|
else
|
||||||
log "nvm could not install Node; skipping"
|
log "nvm could not install Node; skipping"; softly_failed+=("Node (nvm)")
|
||||||
fi
|
fi
|
||||||
set -u
|
set -u
|
||||||
else
|
else
|
||||||
@@ -265,7 +265,7 @@ if [[ -x "$HOME/.bun/bin/bun" ]]; then
|
|||||||
log "Bun already installed at \"$HOME/.bun/bin/bun\""
|
log "Bun already installed at \"$HOME/.bun/bin/bun\""
|
||||||
else
|
else
|
||||||
log "Installing Bun via curl..."
|
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
|
fi
|
||||||
|
|
||||||
# Claude Code: Anthropic's CLI. The official installer keeps itself updated
|
# 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)\""
|
log "Claude Code already installed at \"$(command -v claude)\""
|
||||||
else
|
else
|
||||||
log "Installing Claude Code via the official installer..."
|
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
|
fi
|
||||||
|
|
||||||
# Claude Desktop: Anthropic ships macOS and Windows only, so this is a community
|
# Claude Desktop: Anthropic ships macOS and Windows only, so this is a community
|
||||||
@@ -307,7 +307,7 @@ else
|
|||||||
fi
|
fi
|
||||||
log "Installing Claude Desktop..."
|
log "Installing Claude Desktop..."
|
||||||
sudo dnf install -y claude-desktop-extra > /dev/null \
|
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
|
fi
|
||||||
|
|
||||||
# RustDesk: remote desktop. The flatpak cannot register the root-owned system
|
# RustDesk: remote desktop. The flatpak cannot register the root-owned system
|
||||||
@@ -328,9 +328,9 @@ else
|
|||||||
log "Installing RustDesk from $rustdesk_url"
|
log "Installing RustDesk from $rustdesk_url"
|
||||||
# The RPM ships rustdesk.service already enabled, which is what provides
|
# The RPM ships rustdesk.service already enabled, which is what provides
|
||||||
# unattended access; Panama deliberately does not start it a second time.
|
# 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
|
else
|
||||||
log "Could not resolve a RustDesk release; skipping"
|
log "Could not resolve a RustDesk release; skipping"; softly_failed+=("RustDesk")
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -388,12 +388,12 @@ install_extra_category() {
|
|||||||
|
|
||||||
if [[ -n "${dnf_packages// /}" ]]; then
|
if [[ -n "${dnf_packages// /}" ]]; then
|
||||||
log "Installing $name: $dnf_packages"
|
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
|
fi
|
||||||
if [[ -n "${flatpak_ids// /}" ]]; then
|
if [[ -n "${flatpak_ids// /}" ]]; then
|
||||||
log "Installing $name flatpaks: $flatpak_ids"
|
log "Installing $name flatpaks: $flatpak_ids"
|
||||||
sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo > /dev/null
|
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
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,5 +416,10 @@ done
|
|||||||
if (( ${#softly_failed[@]} > 0 )); then
|
if (( ${#softly_failed[@]} > 0 )); then
|
||||||
log "Installed, but these were stepped over:"
|
log "Installed, but these were stepped over:"
|
||||||
printf ' - %s\n' "${softly_failed[@]}"
|
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
|
fi
|
||||||
|
|||||||
Executable
+258
@@ -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'
|
||||||
Reference in New Issue
Block a user