From 646587e316ac44445ae0061ccb9a86c088f25565 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 27 Jul 2026 15:50:28 -0700 Subject: [PATCH] Recover from package file conflicts instead of predicting them pacman refuses to install over a file it doesn't own, so any path an Omarchy package starts shipping that a script had already written by hand aborts the whole upgrade: omarchy-settings-dev: /usr/lib/systemd/user/omarchy-fcitx5.service exists in filesystem Errors occurred, no packages were upgraded. The mitigation was a hand-maintained --overwrite allowlist, and it only worked with foresight: an entry had to ship a release before the package took the path, because pacman checks conflicts during transaction prepare, so the script running the upgrade is the one already on disk. A missed entry left people hard-stuck, since the upgrade that would deliver the entry is the one refusing to run. So react instead. omarchy-update-system-pkgs now does the ordinary thing and hands a failed transaction to omarchy-update-system-pkgs-when- conflicted, which moves the offending files out of the way and runs the upgrade again. Nothing has to be predicted, and the allowlist is gone. Moving rather than overwriting is what makes it small: no --overwrite argument to build, no glob escaping, no separate backup step, and a leftover directory is cleared too, which --overwrite cannot do at all. Files go to /var/lib/omarchy/replaced/, not next to the original. A sibling copy is not inert -- SDDM reads every file in sddm.conf.d whatever its extension, and systemd-sleep runs every executable in system-sleep -- and the directory a future path lives in is unknowable, which is the whole point of a mechanism for paths nobody predicted. What it will not do: - Take a file another package owns. pacman reports those with a "(owned by x)" suffix, so the end anchor excludes them, and pacman -Qo re-checks the live database before anything moves. - Move a subset. If any reported conflict isn't recoverable the retry is doomed anyway, and moving leaves that config inactive for nothing -- worse than the stuck-but-intact state. - Leave anything inactive that was live. A failed retry, a failed move partway through the loop, or an interrupt all put back whatever the upgrade didn't install. - Act on a report handed to it by hand. It is internal to the update, and an old report would clear live files for an upgrade that isn't happening. Also adds a test that fails when a script writes a path under /usr that no PKGBUILD installs, since not creating these is cheaper than recovering from them. It reads the destination off the command, so a path assembled from variables still slips through; the two known cases are recorded with their reasons. Co-Authored-By: Claude Opus 5 (1M context) --- bin/omarchy-update-system-pkgs | 59 ++-- ...omarchy-update-system-pkgs-when-conflicted | 88 ++++++ test/shell.d/unowned-system-paths-test.sh | 134 +++++++++ test/shell.d/update-file-conflict-test.sh | 276 ++++++++++++++++++ 4 files changed, 519 insertions(+), 38 deletions(-) create mode 100755 bin/omarchy-update-system-pkgs-when-conflicted create mode 100755 test/shell.d/unowned-system-paths-test.sh create mode 100755 test/shell.d/update-file-conflict-test.sh diff --git a/bin/omarchy-update-system-pkgs b/bin/omarchy-update-system-pkgs index 7b197504..15b028d5 100755 --- a/bin/omarchy-update-system-pkgs +++ b/bin/omarchy-update-system-pkgs @@ -7,42 +7,25 @@ set -e echo -e "\e[32m\nUpdate system packages\e[0m" -# Transition --overwrite: previous script-installed Omarchy wrote these paths -# as unowned files; pacman would refuse the omarchy-settings upgrade on first -# encounter. Drop each entry once the transition release is the baseline. +errors=$(mktemp) +trap 'rm -f "$errors"' EXIT + +# /usr/share/omarchy is wholly Omarchy's; files can land there unowned and would +# otherwise abort the upgrade. Packaged content wins there. # -# An entry has to ship at least one release before the package starts owning the -# path. pacman checks file conflicts during transaction prepare, so the copy of -# this script running the upgrade is the one already on disk. -# -# The /usr/share/omarchy/* entry is permanent: that tree is wholly owned by -# the omarchy packages, but files can land there unowned (in-place extension -# work, script-written files), which would abort the whole upgrade. Packaged -# content always wins there. -sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syu --noconfirm \ - --overwrite '/etc/docker/daemon.json' \ - --overwrite '/etc/gnupg/dirmngr.conf' \ - --overwrite '/etc/mkinitcpio.conf.d/omarchy_hooks.conf' \ - --overwrite '/etc/mkinitcpio.conf.d/thunderbolt_module.conf' \ - --overwrite '/etc/modprobe.d/omarchy-usb-autosuspend.conf' \ - --overwrite '/etc/sddm.conf.d/10-theme.conf' \ - --overwrite '/etc/sddm.conf.d/10-wayland.conf' \ - --overwrite '/etc/sudoers.d/omarchy-asdcontrol' \ - --overwrite '/etc/sudoers.d/omarchy-passwd-tries' \ - --overwrite '/etc/sudoers.d/omarchy-tzupdate' \ - --overwrite '/etc/sysctl.d/90-omarchy-file-watchers.conf' \ - --overwrite '/etc/sysctl.d/99-omarchy-sysctl.conf' \ - --overwrite '/etc/systemd/logind.conf.d/10-ignore-power-button.conf' \ - --overwrite '/etc/systemd/resolved.conf.d/10-disable-multicast.conf' \ - --overwrite '/etc/systemd/resolved.conf.d/20-docker-dns.conf' \ - --overwrite '/etc/systemd/system.conf.d/10-faster-shutdown.conf' \ - --overwrite '/etc/systemd/system/user@.service.d/10-faster-shutdown.conf' \ - --overwrite '/etc/systemd/system/docker.service.d/no-block-boot.conf' \ - --overwrite '/etc/systemd/system/plocate-updatedb.service.d/ac-only.conf' \ - --overwrite '/etc/systemd/system.conf.d/20-omarchy-nofile.conf' \ - --overwrite '/etc/systemd/user.conf.d/20-omarchy-nofile.conf' \ - --overwrite '/usr/lib/systemd/system-sleep/unmount-fuse' \ - --overwrite '/usr/share/plymouth/themes/omarchy/*' \ - --overwrite '/usr/share/sddm/hyprland.lua' \ - --overwrite '/usr/share/sddm/themes/omarchy/*' \ - --overwrite '/usr/share/omarchy/*' +# Progress bars stay on stdout. Errors are on stderr, kept for the conflict +# handler below; LC_ALL=C is what keeps them parseable in any locale. +if sudo env LC_ALL=C OMARCHY_UPDATE_PACMAN=1 pacman -Syu --noconfirm \ + --overwrite '/usr/share/omarchy/*' 2>"$errors"; then + cat "$errors" >&2 + exit 0 +fi +cat "$errors" >&2 + +# An upgrade blocked only by files pacman doesn't own yet is the one failure +# worth retrying: the handler clears them and runs this again. Anything else, +# including a second failure, is for a human. +[[ ${OMARCHY_UPDATE_RETRY:-} != 1 ]] || exit 1 +# exec, so the EXIT trap above does not fire and the handler can still read the +# report. It takes over deleting it. +exec env OMARCHY_UPDATE_CONFLICT=1 omarchy-update-system-pkgs-when-conflicted "$errors" diff --git a/bin/omarchy-update-system-pkgs-when-conflicted b/bin/omarchy-update-system-pkgs-when-conflicted new file mode 100755 index 00000000..7b071e2c --- /dev/null +++ b/bin/omarchy-update-system-pkgs-when-conflicted @@ -0,0 +1,88 @@ +#!/bin/bash + +# Internal to omarchy-update-system-pkgs. Not a command to run by hand: it acts +# on a pacman error report, and an old or hand-written one would move live files +# out of the way for an upgrade that is not happening. +# +# Paths written by a script rather than installed by a package -- an earlier +# release's installer, an in-place edit -- belong to nobody, and pacman refuses +# to install over a file it doesn't own. This clears them and runs the upgrade +# again. + +set -e + +[[ ${OMARCHY_UPDATE_CONFLICT:-} == 1 ]] || { + echo "omarchy-update-system-pkgs-when-conflicted runs as part of omarchy update" >&2 + exit 1 +} + +errors=${1:?usage: omarchy-update-system-pkgs-when-conflicted } + +# Where a file pacman is taking over gets moved, mirroring its full path. Kept +# out of the directory it came from: SDDM reads every file in sddm.conf.d +# whatever its extension, and systemd-sleep runs every executable in +# system-sleep, so a copy left beside the original would still be live. +replaced=${OMARCHY_REPLACED_DIR:-/var/lib/omarchy/replaced} + +# Anything moved that the upgrade didn't end up installing goes back, whether +# the retry failed or the move loop itself was interrupted partway. Nothing here +# should leave configuration inactive that was live when it started. +# +# -e follows symlinks, so a dangling one reads as absent. -L catches those: a +# link pacman installed, and a relative link that no longer resolves from inside +# the quarantine. +moved=() +restore_moved() { + local path restorable=() + + for path in "${moved[@]}"; do + if [[ ! -e $path && ! -L $path ]] && [[ -e $replaced$path || -L $replaced$path ]]; then + restorable+=("$path") + fi + done + ((${#restorable[@]})) || return 0 + + echo -e "\e[33m\nPutting back what the upgrade didn't take:\e[0m" + for path in "${restorable[@]}"; do + sudo mv -T "$replaced$path" "$path" + echo " $path" + done +} + +# exec'd into, so the caller's EXIT trap never ran and the report is still here. +# Cleaning it up is this script's job from now on. +trap 'restore_moved; rm -f "$errors"' EXIT +trap 'exit 1' INT TERM + +# Paths one of these packages installs that pacman doesn't own. Moving them out +# of the way is what lets the upgrade through, and works on a leftover directory +# as well as a file. +# +# "pkg: /path exists in filesystem" is unowned; the same line plus "(owned by +# x)" is not, so the end anchor takes only ours. -Qo re-checks the live database +# before anything moves. +mapfile -t leftovers < <( + grep -oP '^omarchy(-dev|-settings|-settings-dev)?: \K.+(?= exists in filesystem$)' "$errors" | + while IFS= read -r path; do pacman -Qo "$path" &>/dev/null || echo "$path"; done +) +((${#leftovers[@]})) || exit 1 + +# All of them or none: moving a subset leaves the retry blocked by the rest, and +# the moved files inactive for nothing. +((${#leftovers[@]} == $(grep -c ' exists in filesystem' "$errors"))) || exit 1 + +echo -e "\e[33m\nTaking over files pacman doesn't own yet:\e[0m" +for path in "${leftovers[@]}"; do + sudo mkdir -p "$replaced${path%/*}" + # -T so an existing directory at the destination is replaced, not moved into. + sudo mv -T --backup=numbered "$path" "$replaced$path" + moved+=("$path") + echo " $path -> $replaced$path" +done +echo + +if OMARCHY_UPDATE_RETRY=1 omarchy-update-system-pkgs; then + moved=() + exit 0 +fi +exit 1 diff --git a/test/shell.d/unowned-system-paths-test.sh b/test/shell.d/unowned-system-paths-test.sh new file mode 100755 index 00000000..f3bec23c --- /dev/null +++ b/test/shell.d/unowned-system-paths-test.sh @@ -0,0 +1,134 @@ +#!/bin/bash + +# A file Omarchy writes into /usr belongs to nobody, and the +# day a package starts shipping that same path, pacman refuses the upgrade for +# everyone who has the file. omarchy-update-system-pkgs-when-conflicted recovers from +# that, but the cheaper answer is to ship the file in the package instead. +# +# This flags a script writing such a path unless a PKGBUILD installs it, or it +# is recorded below with the reason it cannot be packaged. +# +# It is a net, not a proof: it reads the destination off the command, so a path +# assembled from variables passes through. The two known cases are recorded +# below, and a new one is caught only if it names the path where it writes it. + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +python3 - "$ROOT" <<'PYTHON' +import os, re, sys +from pathlib import Path + +root = Path(sys.argv[1]) + +# Paths Omarchy writes into /usr that no package owns, with the reason. +allowed = { + # Symlinks into another package's icon theme; owning them would mean owning + # paths inside Yaru. + "/usr/share/icons/Yaru/scalable/actions", + # Hardware-conditional sleep hooks, installed only on the machines that need + # them so the hook does not exist where it does not apply. + "/usr/lib/systemd/system-sleep", + # Written through a variable, so the scan below cannot see them at the point + # they are written. Both drop configuration into another project's tree rather + # than Omarchy's, which is why neither is a candidate for omarchy-settings. + "/usr/share/chromium/extensions", + "/usr/lib/firefox/distribution", + # Static content that belongs in omarchy-settings. It cannot move there in the + # same release that first ships omarchy-update-system-pkgs-when-conflicted: the + # upgrade carrying the handler is the one that would hit the conflict, and the + # handler only helps once it is on disk. Package it the release after. + "/usr/lib/chromium/initial_preferences", +} + +# One-time 3.x upgrade. It runs before this rule existed and cannot be made to +# retroactively matter for machines that already ran it. +skip_scripts = {"bin/omarchy-upgrade-to-quattro"} + +pkgs_candidates = [ + root.parent / "omarchy-pkgs/pkgbuilds", + root.parent.parent / "omarchy-pkgs/pkgbuilds", + root.parent / "omacom/omarchy-pkgs/pkgbuilds", + Path.home() / "Work/omacom/omarchy-pkgs/pkgbuilds", +] +override = os.environ.get("OMARCHY_PKGS_PATH") +if override: + pkgs_candidates = [Path(override) / "pkgbuilds", Path(override)] + pkgs_candidates +pkgs_root = next((p for p in pkgs_candidates if p.exists()), None) +if pkgs_root is None: + print("not ok - omarchy-pkgs checkout found for package ownership check", file=sys.stderr) + sys.exit(1) + +packaged = "\n".join(p.read_text() for p in pkgs_root.glob("*/PKGBUILD")) + +# Commands that put a file somewhere, as opposed to reading one. +# /etc is administrator territory that Omarchy legitimately edits. /usr is +# package territory, where writing anything is the thing worth catching. +writer = re.compile(r"\b(tee|cp|install|ln)\b|>\s*/usr/") +target = re.compile(r"/usr/[A-Za-z0-9._@/+-]+") + +problems = [] +for base in ("bin", "install", "migrations"): + for path in sorted((root / base).rglob("*")): + if not path.is_file(): + continue + rel = str(path.relative_to(root)) + if rel in skip_scripts: + continue + # Join continuations first, so a command split across lines is judged whole + # rather than as a source path on one line and a destination on the next. + joined, buf, start = [], "", 1 + for lineno, line in enumerate(path.read_text(errors="ignore").splitlines(), 1): + if not buf: + start = lineno + if line.rstrip().endswith("\\"): + buf += line.rstrip()[:-1] + " " + continue + joined.append((start, buf + line)) + buf = "" + if buf: + joined.append((start, buf)) + + for lineno, line in joined: + code = line.split("#", 1)[0] + if not writer.search(code): + continue + # A redirection names its destination directly. Otherwise cp/install/ln + # put the destination last, and anything earlier is a source being read. + redirect = re.search(r">\s*\"?(/usr/[^\s\"]+)", code) + if redirect: + hit = redirect.group(1).rstrip("/") + else: + tokens = re.sub(r"[12]?>\s*\S+", "", code).split() + if not tokens or not tokens[-1].startswith("/usr/"): + continue + hit = tokens[-1].rstrip("/") + # Omarchy's own tree and its binaries are covered elsewhere. + if hit.startswith(("/usr/share/omarchy", "/usr/bin")) or hit.count("/") < 3: + continue + # Directory or file form of a recorded path both count as recorded, but + # only on a path boundary: system-sleeping is not system-sleep. + def covers(a, b): + return a == b or b.startswith(a + "/") + if any(covers(a, hit) or covers(hit, a) for a in allowed): + continue + # Likewise in the PKGBUILD text, where the path is a destination rather + # than a prefix of a longer one. + if re.search(re.escape(hit) + r'(?=["\'\s]|$)', packaged, re.M): + continue + problems.append(f"{rel}:{lineno}: {hit}") + +if problems: + print("not ok - Omarchy writes paths under /usr that no package owns", file=sys.stderr) + for p in problems: + print(f" {p}", file=sys.stderr) + print( + "\nShip it from a PKGBUILD so pacman owns it, or record it in this test\n" + "with the reason it cannot be packaged.", + file=sys.stderr, + ) + sys.exit(1) +PYTHON + +pass "no Omarchy script writes a path under /usr that no package owns" diff --git a/test/shell.d/update-file-conflict-test.sh b/test/shell.d/update-file-conflict-test.sh new file mode 100755 index 00000000..5fa954ea --- /dev/null +++ b/test/shell.d/update-file-conflict-test.sh @@ -0,0 +1,276 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +stub_bin="$test_tmp/bin" +mkdir -p "$stub_bin" + +cat >"$stub_bin/sudo" <<'STUB' +#!/bin/bash +exec "$@" +STUB + +# Fails the first -Syu with the report under test, then succeeds unless the case +# asked for the retry to fail too. +cat >"$stub_bin/pacman" <<'STUB' +#!/bin/bash +if [[ $1 == -Qo ]]; then + # Anything in OWNED_PATHS has a package behind it; everything else is unowned. + [[ " $OWNED_PATHS " == *" $2 "* ]] + exit $? +fi + +attempt=$(($(cat "$PACMAN_ATTEMPTS") + 1)) +echo "$attempt" >"$PACMAN_ATTEMPTS" +if ((attempt == 1)); then + cat "$CONFLICT_REPORT" >&2 + exit 1 +fi +if [[ -n ${RETRY_FAILS:-} ]]; then + # Optionally commit the file first, as a partial transaction would. + [[ -n ${RETRY_INSTALLS:-} ]] && echo "packaged" >"$RETRY_INSTALLS" + echo "error: failed to retrieve some files" >&2 + exit 1 +fi +echo "upgrade complete" +STUB + +chmod +x "$stub_bin/sudo" "$stub_bin/pacman" + +replaced="$test_tmp/replaced" + +run_update() { + OMARCHY_REPLACED_DIR="$replaced" \ + RETRY_FAILS="${RETRY_FAILS:-}" \ + RETRY_INSTALLS="${RETRY_INSTALLS:-}" \ + PACMAN_ATTEMPTS="$test_tmp/attempts" \ + CONFLICT_REPORT="$test_tmp/report" \ + OWNED_PATHS="${OWNED_PATHS:-}" \ + PATH="$stub_bin:$ROOT/bin:$PATH" \ + bash "$ROOT/bin/omarchy-update-system-pkgs" +} + +# $1 blamed package, $2 path, $3 optional owning package. +write_report() { + echo 0 >"$test_tmp/attempts" + { + echo "error: failed to commit transaction (conflicting files)" + echo "$1: $2 exists in filesystem${3:+ (owned by $3)}" + } >"$test_tmp/report" +} + +# Raw conflict lines, for reports the recovery must refuse wholesale. +write_raw_report() { + echo 0 >"$test_tmp/attempts" + { + echo "error: failed to commit transaction (conflicting files)" + printf '%s\n' "$@" + } >"$test_tmp/report" +} + +work="$test_tmp/work" +fresh_work() { + rm -rf "$work" "$replaced" + mkdir -p "$work" +} + +# An unowned path one of the packages is taking over. +fresh_work +stray="$work/omarchy-fcitx5.service" +echo "stray content" >"$stray" +write_report omarchy-settings-dev "$stray" +run_update >"$test_tmp/out" 2>"$test_tmp/err" || + fail "an unowned file conflict is not resolved" +[[ ! -e $stray ]] || + fail "the file is left in pacman's way" +pass "a file pacman is taking over is moved out of its way" + +# Kept out of the directory it came from, where SDDM and systemd-sleep read +# every file and every executable respectively. +[[ -z $(ls -A "$work") ]] || + fail "something is left in the directory the replaced file came from" +grep -qx "stray content" "$replaced$stray" || + fail "the replaced file is destroyed rather than kept out of the way" +pass "the replaced file is quarantined outside the directory it came from" + +# A real fight between packages, not Omarchy's leftovers. pacman appends +# "(owned by ...)" here. +fresh_work +echo "theirs" >"$stray" +write_report omarchy-settings-dev "$stray" someone-else +if run_update >"$test_tmp/out" 2>"$test_tmp/err"; then + fail "a file owned by another package is silently taken" +fi +[[ -e $stray && ! -e $replaced$stray ]] || + fail "a file owned by another package is silently taken" +pass "a conflict owned by another package stops the upgrade instead of being taken" + +# Report reads unowned, database disagrees: the parse is never the only thing +# between a retry and another package's file. +fresh_work +echo "theirs" >"$stray" +write_report omarchy-settings-dev "$stray" +if OWNED_PATHS="$stray" run_update >"$test_tmp/out" 2>"$test_tmp/err"; then + fail "pacman -Qo is not consulted before moving a file" +fi +[[ -e $stray && ! -e $replaced$stray ]] || + fail "pacman -Qo is not consulted before moving a file" +pass "an owned path is left alone even when the report reads as unowned" + +# A name prefix is not a namespace; only the packages that own system paths. +fresh_work +echo "stray" >"$stray" +write_report omarchy-chromium-bin "$stray" +if run_update >"$test_tmp/out" 2>"$test_tmp/err"; then + fail "an optional omarchy-prefixed package gets its conflicts auto-resolved" +fi +pass "only the packages that own system paths get their conflicts resolved" + +# Not Omarchy's conflict to resolve. +fresh_work +echo "stray" >"$stray" +write_report some-other-pkg "$stray" +if run_update >"$test_tmp/out" 2>"$test_tmp/err"; then + fail "a conflict from an unrelated package is auto-resolved" +fi +pass "a conflict from a non-Omarchy package is left for a human" + +# The path is used literally, so glob characters in a name mean nothing. +fresh_work +globby="$work/omarchy-[1].conf" +echo "globby" >"$globby" +write_report omarchy-settings-dev "$globby" +run_update >"$test_tmp/out" 2>"$test_tmp/err" || + fail "a path whose name contains glob characters is not resolved" +[[ -f "$replaced$globby" && ! -e "$globby" ]] || + fail "a path whose name contains glob characters is treated as a pattern" +pass "a path whose name would act as a glob is moved literally" + +# A leftover directory is cleared the same way a file is. +fresh_work +conflict_dir="$work/omarchy-dir" +mkdir -p "$conflict_dir" +write_report omarchy-settings-dev "$conflict_dir" +run_update >"$test_tmp/out" 2>"$test_tmp/err" || + fail "a conflicting directory is not cleared out of pacman's way" +[[ -d "$replaced$conflict_dir" && ! -e $conflict_dir ]] || + fail "a conflicting directory is left in place" +pass "a conflicting directory is moved away" + +# A space is legal in a package path; the parse must not truncate it. +fresh_work +spaced="$work/omarchy theme.conf" +echo "spaced" >"$spaced" +write_report omarchy-settings-dev "$spaced" +run_update >"$test_tmp/out" 2>"$test_tmp/err" || + fail "a conflicting path containing a space is not resolved" +[[ -f "$replaced$spaced" && ! -e "$spaced" ]] || + fail "a conflicting path containing a space is truncated" +pass "a conflicting path containing a space is parsed whole" + +# An earlier quarantined copy is the more original one, and might not be ours. +fresh_work +echo "current" >"$stray" +mkdir -p "$replaced$work" +echo "from an earlier run" >"$replaced$stray" +write_report omarchy-settings-dev "$stray" +run_update >"$test_tmp/out" 2>"$test_tmp/err" || + fail "a conflict with an existing quarantined copy is not resolved" +grep -qrx "from an earlier run" "$replaced" || + fail "an existing quarantined copy is destroyed to make room for a new one" +pass "an existing quarantined copy survives a later run needing the same name" + +# mv without -T would move the source inside an existing destination directory. +fresh_work +echo "ours" >"$stray" +mkdir -p "$replaced$stray" +write_report omarchy-settings-dev "$stray" +run_update >"$test_tmp/out" 2>"$test_tmp/err" || + fail "a conflict whose destination is a directory is not resolved" +[[ -f "$replaced$stray" ]] || + fail "the leftover was moved inside the existing destination directory" +pass "an existing destination directory is replaced, not moved into" + +# One healable conflict beside one that is not. Moving only the first leaves the +# retry blocked by the second, and that config inactive for nothing. +fresh_work +echo "ours" >"$stray" +write_raw_report \ + "omarchy-settings-dev: $stray exists in filesystem" \ + "some-package: $work/theirs exists in filesystem (owned by other-package)" +if run_update >"$test_tmp/out" 2>"$test_tmp/err"; then + fail "an upgrade with an unhealable conflict reports success" +fi +[[ -e $stray && ! -e $replaced$stray ]] || + fail "a healable conflict is moved even though another conflict dooms the retry" +pass "nothing moves unless every reported conflict is healable" + +# The retry can still fail for an unrelated reason. Leave nothing inactive. +fresh_work +echo "ours" >"$stray" +write_report omarchy-settings-dev "$stray" +if RETRY_FAILS=1 run_update >"$test_tmp/out" 2>"$test_tmp/err"; then + fail "a failed retry reports success" +fi +[[ -f $stray ]] || + fail "a failed retry leaves the file moved away and inactive" +grep -qx "ours" "$stray" || + fail "the restored file is not the original content" +[[ $(cat "$test_tmp/attempts") == 2 ]] || + fail "the handler and the upgrade re-invoke each other instead of stopping" +pass "a failed retry puts the files back, without re-invoking the handler" + +# A retry that failed after committing the files has nothing to restore, and +# should not announce a restore it is not doing. +fresh_work +echo "ours" >"$stray" +write_report omarchy-settings-dev "$stray" +if RETRY_FAILS=1 RETRY_INSTALLS="$stray" run_update >"$test_tmp/out" 2>"$test_tmp/err"; then + fail "a failed retry reports success" +fi +grep -qi "restoring" "$test_tmp/out" "$test_tmp/err" && + fail "a restore is announced when no file is put back" +grep -qx "packaged" "$stray" || + fail "the file pacman installed was overwritten by the restore" +pass "no restore is announced when there is nothing to put back" + +# A dangling symlink reads as absent to -e, so a relative link that no longer +# resolves from inside the quarantine must still be recognised and put back. +fresh_work +ln -s ./neighbour "$stray" +write_report omarchy-settings-dev "$stray" +if RETRY_FAILS=1 run_update >"$test_tmp/out" 2>"$test_tmp/err"; then + fail "a failed retry reports success" +fi +[[ -L $stray ]] || + fail "a symlink that dangles from the quarantine is never restored" +pass "a dangling symlink is restored rather than stranded in the quarantine" + +# The handler acts on a pacman report; an old or hand-written one would move +# live files aside for an upgrade that is not happening. +fresh_work +echo "ours" >"$stray" +write_report omarchy-settings-dev "$stray" +if PATH="$stub_bin:$ROOT/bin:$PATH" OMARCHY_REPLACED_DIR="$replaced" \ + bash "$ROOT/bin/omarchy-update-system-pkgs-when-conflicted" "$test_tmp/report" \ + >"$test_tmp/out" 2>"$test_tmp/err"; then + fail "the handler acts on a report handed to it outside an update" +fi +[[ -f $stray ]] || + fail "the handler moved a live file when run outside an update" +pass "the handler refuses a report handed to it outside an update" + +# The happy path must not pay for any of this. +fresh_work +: >"$test_tmp/report" +echo 1 >"$test_tmp/attempts" +run_update >"$test_tmp/out" 2>"$test_tmp/err" || + fail "a clean upgrade fails" +[[ $(cat "$test_tmp/attempts") == 2 ]] || + fail "a clean upgrade runs more than one pacman transaction" +pass "a clean upgrade runs a single pacman transaction"