Files
omarchycn/test/shell.d/update-file-conflict-test.sh
David Heinemeier HanssonandClaude Opus 5 646587e316 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/<original path>, 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) <noreply@anthropic.com>
2026-07-27 15:50:28 -07:00

277 lines
10 KiB
Bash
Executable File

#!/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"