From 367774cfb0d84127f04ba68005638c4ba2174c37 Mon Sep 17 00:00:00 2001 From: Simon Bourdon Date: Tue, 1 Sep 2026 20:30:53 -0400 Subject: [PATCH] Make Mac config upgrades safely resumable --- bin/omarchy-upgrade-to-quattro-mac | 467 +++++++++++++- docs/upgrade-to-quattro.md | 26 +- test/shell.d/upgrade-to-quattro-mac-test.sh | 641 ++++++++++++++++++++ 3 files changed, 1120 insertions(+), 14 deletions(-) diff --git a/bin/omarchy-upgrade-to-quattro-mac b/bin/omarchy-upgrade-to-quattro-mac index 88996d78690..1061b42ca93 100755 --- a/bin/omarchy-upgrade-to-quattro-mac +++ b/bin/omarchy-upgrade-to-quattro-mac @@ -115,11 +115,142 @@ while true; do sudo -n true; sleep 60; done 2>/dev/null & sudo_keepalive_pid=$! trap '[[ -n $sudo_keepalive_pid ]] && kill "$sudo_keepalive_pid" 2>/dev/null || true' EXIT -backup_suffix=$(date +%Y%m%d%H%M%S) +upgrade_state_dir="$HOME/.local/state/omarchy/upgrade-to-quattro-mac" +backup_identity_file="$upgrade_state_dir/backup-identity" +backup_absent_file="$upgrade_state_dir/config-backup-absent" +config_transition_file="$upgrade_state_dir/config-transition-complete" +config_transition_lock="$upgrade_state_dir/config-transition.lock" +hypr_backup_state_file="$upgrade_state_dir/hypr-backup-state" +omarchy_backup_state_file="$upgrade_state_dir/omarchy-backup-state" +backup_suffix="" + +is_valid_backup_identity() { + [[ $1 =~ ^[0-9]{14}-[[:alnum:]]{6}$ ]] +} + +acquire_config_transition_lock() { + if [[ -L $upgrade_state_dir || ( -e $upgrade_state_dir && ! -d $upgrade_state_dir ) ]]; then + fail "The Quattro upgrade state path is not a directory: $upgrade_state_dir" + fi + mkdir -p "$upgrade_state_dir" + [[ -d $upgrade_state_dir && ! -L $upgrade_state_dir ]] || + fail "The Quattro upgrade state path is not a directory: $upgrade_state_dir" + [[ ! -L $config_transition_lock ]] || + fail "The Quattro upgrade lock path must not be a symlink: $config_transition_lock" + [[ ! -e $config_transition_lock || -f $config_transition_lock ]] || + fail "The Quattro upgrade lock path is not a regular file: $config_transition_lock" + + # Keep the descriptor open until the script exits so the lock spans backup, + # staging, and publication. Use a fixed descriptor for the macOS Bash 3 + # runtime; the standalone upgrade keeps descriptor 9 open until it exits. + exec 9>"$config_transition_lock" + flock -n 9 || + fail "Another Quattro config transition is already running; wait for it to finish and retry." +} + +owned_marker_matches_identity() { + local marker="$1" + + [[ ! -L $marker && -f $marker ]] || return 1 + [[ $(<"$marker") == "$backup_suffix" ]] +} + +write_state_marker() { + local path="$1" value="$2" temporary + + [[ ! -L $path ]] || fail "The Quattro state marker must not be a symlink: $path" + [[ ! -e $path || -f $path ]] || fail "The Quattro state marker is not a regular file: $path" + temporary=$(mktemp "$upgrade_state_dir/.state.XXXXXX") + printf '%s\n' "$value" >"$temporary" + mv "$temporary" "$path" +} + +write_owned_marker() { + local path="$1" value="$2" temporary + + [[ ! -L $path ]] || fail "The Quattro completion marker must not be a symlink: $path" + temporary=$(mktemp "${path}.XXXXXX") + printf '%s\n' "$value" >"$temporary" + mv "$temporary" "$path" +} + +load_or_create_backup_identity() { + local temporary random candidate + + [[ -L $backup_identity_file ]] && + fail "The Quattro upgrade identity must not be a symlink: $backup_identity_file" + if [[ -e $backup_identity_file ]]; then + [[ -f $backup_identity_file ]] || fail "The Quattro upgrade identity is not a regular file: $backup_identity_file" + backup_suffix=$(<"$backup_identity_file") + is_valid_backup_identity "$backup_suffix" || + fail "The Quattro upgrade identity is invalid; remove $backup_identity_file only if you are recovering the upgrade manually." + return 0 + fi + + # mktemp supplies six random alphanumeric characters. Persist the resulting + # identity before copying any user data, so a retry cannot choose a second + # backup after an interruption. + while true; do + temporary=$(mktemp "$upgrade_state_dir/.backup-identity.XXXXXX") + random=${temporary##*.} + backup_suffix="$(date +%Y%m%d%H%M%S)-$random" + candidate="$HOME/.config.omarchy3.$backup_suffix.bak" + if [[ ! -e $candidate && ! -L $candidate && ! -e $candidate.partial && ! -L $candidate.partial ]]; then + printf '%s\n' "$backup_suffix" >"$temporary" + mv "$temporary" "$backup_identity_file" + return 0 + fi + rm -f "$temporary" + done +} backup_user_config() { - log "Backing up ~/.config to ~/.config.omarchy3.$backup_suffix.bak" - cp -a "$HOME/.config" "$HOME/.config.omarchy3.$backup_suffix.bak" + local backup_root backup_marker backup_partial + + acquire_config_transition_lock + load_or_create_backup_identity + backup_root="$HOME/.config.omarchy3.$backup_suffix.bak" + backup_marker="$backup_root/.omarchy-upgrade-to-quattro-config-backup-complete" + backup_partial="$backup_root.partial" + + [[ ! -L $backup_root && ! -L $backup_partial ]] || + fail "The selected Quattro backup path must not be a symlink: $backup_root" + if [[ -e $backup_absent_file || -L $backup_absent_file ]]; then + [[ ! -L $backup_absent_file && -f $backup_absent_file ]] || + fail "The Quattro absent-config marker is not a regular file: $backup_absent_file" + [[ $(<"$backup_absent_file") == absent ]] || + fail "The Quattro absent-config marker has unexpected content: $backup_absent_file" + [[ ! -e $backup_marker && ! -L $backup_marker ]] || + fail "The Quattro state says ~/.config is absent but also has a complete backup: $backup_root" + log "Reusing the recorded absence of ~/.config" + return 0 + fi + + if [[ -e $backup_marker || -L $backup_marker ]]; then + [[ ! -L $backup_marker ]] || fail "The Quattro backup completion marker must not be a symlink: $backup_marker" + owned_marker_matches_identity "$backup_marker" || + fail "The Quattro backup completion marker has unexpected content: $backup_marker" + log "Reusing the existing ~/.config backup at $backup_root" + return 0 + fi + [[ ! -e $backup_root ]] || + fail "The selected Quattro backup path already exists without a completion marker: $backup_root" + + if [[ ! -e $HOME/.config ]]; then + log "No ~/.config exists; recording that there is no user config to back up" + write_state_marker "$backup_absent_file" absent + return 0 + fi + + [[ ! -e $backup_partial ]] || rm -rf "$backup_partial" + log "Backing up ~/.config to $backup_root" + mkdir "$backup_partial" + if ! cp -a "$HOME/.config/." "$backup_partial/"; then + rm -rf "$backup_partial" + fail "Could not back up ~/.config. Nothing in the active config was changed; retry the Quattro upgrade." + fi + write_owned_marker "$backup_partial/.omarchy-upgrade-to-quattro-config-backup-complete" "$backup_suffix" + mv "$backup_partial" "$backup_root" } switch_checkout_to_quattro() { @@ -292,15 +423,333 @@ update_bashrc() { mv "$rewritten" "$bashrc" } +config_backup_path() { + local base="$1" suffix=1 candidate="$1" + + while [[ -e $candidate || -L $candidate ]]; do + candidate="$base.$suffix" + ((suffix++)) + done + printf '%s\n' "$candidate" +} + +publish_config_transition() { + local status="$1" identity recorded_status + + [[ $status == adopted || $status == published ]] || + fail "The Quattro config transition marker has an invalid status: $status" + if [[ -e $config_transition_file || -L $config_transition_file ]]; then + [[ ! -L $config_transition_file && -f $config_transition_file ]] || + fail "The Quattro config transition marker is not a regular file: $config_transition_file" + (( $(wc -l <"$config_transition_file") == 2 )) || + fail "The Quattro config transition marker has unexpected content: $config_transition_file" + identity=$(sed -n '1p' "$config_transition_file") + recorded_status=$(sed -n '2p' "$config_transition_file") + [[ $identity == "$backup_suffix" && ( $recorded_status == adopted || $recorded_status == published ) ]] || + fail "The Quattro config transition marker has unexpected content: $config_transition_file" + fi + write_state_marker "$config_transition_file" "$backup_suffix +$status" +} + +tree_state_matches() { + local state_file="$1" kind="$2" active="$3" + local identity backup target target_backup status prefix suffix + + [[ ! -L $state_file && -f $state_file ]] || return 1 + (( $(wc -l <"$state_file") == 5 )) || return 1 + identity=$(sed -n '1p' "$state_file") + backup=$(sed -n '2p' "$state_file") + target=$(sed -n '3p' "$state_file") + target_backup=$(sed -n '4p' "$state_file") + status=$(sed -n '5p' "$state_file") + [[ $identity == "$backup_suffix" && ( $status == pending || $status == published ) ]] || return 1 + + prefix="$HOME/.config/$kind.omarchy3.$backup_suffix.bak" + [[ $backup == "$prefix" || $backup == "$prefix."* ]] || return 1 + suffix=${backup#"$prefix"} + [[ -z $suffix || $suffix =~ ^\.[0-9]+$ ]] || return 1 + if [[ -n $target ]]; then + [[ -L $active ]] || return 1 + [[ $(readlink -f "$active") == "$target" ]] || return 1 + [[ $target_backup == "$target.omarchy3.$backup_suffix.bak" || $target_backup == "$target.omarchy3.$backup_suffix.bak."* ]] || return 1 + else + [[ -z $target_backup && ! -L $active ]] || return 1 + fi +} + +write_tree_state() { + local state_file="$1" backup="$2" target="$3" target_backup="$4" status="$5" + + write_state_marker "$state_file" "$backup_suffix +$backup +$target +$target_backup +$status" +} + +load_tree_state() { + local state_file="$1" kind="$2" active="$3" proposed_backup="$4" + local target target_backup + + tree_backup_path="$proposed_backup" + tree_target_path="" + tree_target_backup="" + tree_publication_status=pending + if [[ -e $state_file || -L $state_file ]]; then + [[ ! -L $state_file ]] || fail "The Quattro config backup state must not be a symlink: $state_file" + tree_state_matches "$state_file" "$kind" "$active" || + fail "The Quattro config backup state is invalid or belongs to another path: $state_file" + tree_backup_path=$(sed -n '2p' "$state_file") + tree_target_path=$(sed -n '3p' "$state_file") + tree_target_backup=$(sed -n '4p' "$state_file") + tree_publication_status=$(sed -n '5p' "$state_file") + return 0 + fi + + tree_backup_path=$(config_backup_path "$proposed_backup") + if [[ -L $active ]]; then + [[ -d $active ]] || fail "The symlinked Quattro config root does not point to a directory: $active" + target=$(readlink -f "$active") + [[ -n $target && -d $target ]] || fail "The symlinked Quattro config root has no directory target: $active" + target_backup=$(config_backup_path "$target.omarchy3.$backup_suffix.bak") + tree_target_path="$target" + tree_target_backup="$target_backup" + fi + write_tree_state "$state_file" "$tree_backup_path" "$tree_target_path" "$tree_target_backup" pending +} + +ensure_tree_backup() { + local source="$1" backup="$2" marker partial + + [[ ! -L $backup ]] || fail "The Quattro config backup must not be a symlink: $backup" + marker="$backup/.omarchy-upgrade-to-quattro-config-tree-backup-complete" + partial="$backup.partial" + if [[ -e $backup ]]; then + [[ -d $backup ]] || fail "The Quattro config backup is not a directory: $backup" + if [[ -e $marker || -L $marker ]]; then + [[ ! -L $marker ]] || fail "The Quattro config backup marker must not be a symlink: $marker" + owned_marker_matches_identity "$marker" || + fail "The Quattro config backup marker has unexpected content: $marker" + else + fail "The Quattro config backup has no owned completion marker: $backup" + fi + return 0 + fi + + [[ ! -e $partial && ! -L $partial ]] || rm -rf "$partial" + mkdir "$partial" + if ! cp -a "$source/." "$partial/"; then + rm -rf "$partial" + fail "Could not back up the symlinked config target; retry the Quattro upgrade." + fi + write_owned_marker "$partial/.omarchy-upgrade-to-quattro-config-tree-backup-complete" "$backup_suffix" + mv "$partial" "$backup" +} + +validate_tree_backup_if_present() { + local backup="$1" marker + + [[ ! -L $backup ]] || fail "The Quattro config backup must not be a symlink: $backup" + [[ ! -e $backup ]] && return 0 + [[ -d $backup ]] || fail "The Quattro config backup is not a directory: $backup" + marker="$backup/.omarchy-upgrade-to-quattro-config-tree-backup-complete" + [[ ! -L $marker && -f $marker ]] || fail "The Quattro config backup has no owned completion marker: $backup" + owned_marker_matches_identity "$marker" || fail "The Quattro config backup marker has unexpected content: $marker" +} + +validate_target_backup_if_present() { + local backup="$1" marker + + [[ ! -L $backup ]] || fail "The Quattro target backup must not be a symlink: $backup" + [[ ! -e $backup ]] && return 0 + [[ -d $backup ]] || fail "The Quattro target backup is not a directory: $backup" + marker="$backup/.omarchy-upgrade-to-quattro-target-backup-complete" + [[ ! -L $marker && -f $marker ]] || fail "The Quattro target backup has no owned completion marker: $backup" + owned_marker_matches_identity "$marker" || fail "The Quattro target backup marker has unexpected content: $marker" +} + +publish_config_tree() { + local kind="$1" source="$2" active="$3" proposed_backup="$4" temporary="$5" state_file="$6" + local backup target target_backup target_parent target_temporary target_collision marker backup_marker + + [[ ! -L $source && -d $source ]] || + fail "The Quattro config stage is missing its owned directory: $source" + load_tree_state "$state_file" "$kind" "$active" "$proposed_backup" + backup="$tree_backup_path" + target="$tree_target_path" + target_backup="$tree_target_backup" + + if [[ -n $target ]]; then + backup_marker="$backup/.omarchy-upgrade-to-quattro-config-tree-backup-complete" + if [[ -e $backup && ! -e $backup_marker ]]; then + [[ ! -e $target && ! -L $target ]] || + fail "The Quattro config backup is not owned by this transition: $backup" + write_owned_marker "$backup_marker" "$backup_suffix" + fi + ensure_tree_backup "$active" "$backup" + if [[ $tree_publication_status == published ]]; then + validate_tree_backup_if_present "$backup" + validate_target_backup_if_present "$target_backup" + return 0 + fi + + # Once the original target has been moved to its owned backup, a + # reappeared target is adopted. It may be a completed atomic publication + # or a user repair; replacing either would destroy the active tree. + if [[ -e $target && -e $target_backup ]]; then + validate_target_backup_if_present "$target_backup" + validate_tree_backup_if_present "$backup" + write_tree_state "$state_file" "$backup" "$target" "$target_backup" published + return 0 + fi + + # The target may be absent because the process stopped after moving it to + # the exact state-recorded backup. That path is recoverable evidence for + # this transition; bind it before writing its completion marker. + if [[ ! -e $target && -e $target_backup ]]; then + [[ ! -L $target_backup && -d $target_backup ]] || + fail "The Quattro target backup is not a directory: $target_backup" + marker="$target_backup/.omarchy-upgrade-to-quattro-target-backup-complete" + if [[ ! -e $marker ]]; then + write_owned_marker "$marker" "$backup_suffix" + fi + validate_target_backup_if_present "$target_backup" + fi + + target_parent=${target%/*} + target_temporary="$target_parent/.omarchy3.$backup_suffix.$kind-publish.partial" + [[ ! -L $target_temporary ]] || fail "The Quattro config publication path must not be a symlink: $target_temporary" + [[ ! -e $target_temporary ]] || rm -rf "$target_temporary" + mkdir "$target_temporary" + if ! cp -a "$source/." "$target_temporary/"; then + rm -rf "$target_temporary" + fail "Could not prepare the symlinked config target; retry the Quattro upgrade." + fi + if [[ -e $target || -L $target ]]; then + if [[ ! -e $target_backup ]]; then + mv "$target" "$target_backup" + write_owned_marker "$target_backup/.omarchy-upgrade-to-quattro-target-backup-complete" "$backup_suffix" + else + [[ ! -L $target_backup && -d $target_backup ]] || fail "The Quattro target backup is not a directory: $target_backup" + marker="$target_backup/.omarchy-upgrade-to-quattro-target-backup-complete" + if [[ ! -e $marker ]]; then + [[ ! -e $target && ! -L $target ]] || fail "The Quattro target backup is not owned by this transition: $target_backup" + write_owned_marker "$marker" "$backup_suffix" + fi + [[ ! -L $marker ]] || fail "The Quattro target backup marker must not be a symlink: $marker" + owned_marker_matches_identity "$marker" || fail "The Quattro target backup marker has unexpected content: $marker" + target_collision=$(config_backup_path "$target_backup") + mv "$target" "$target_collision" + fi + fi + mv "$target_temporary" "$target" + write_tree_state "$state_file" "$backup" "$target" "$target_backup" published + return 0 + fi + + backup_marker="$backup/.omarchy-upgrade-to-quattro-config-tree-backup-complete" + if [[ -e $backup && ! -e $backup_marker ]]; then + [[ ! -e $active && ! -L $active ]] || + fail "The Quattro config backup is not owned by this transition: $backup" + write_owned_marker "$backup_marker" "$backup_suffix" + fi + + if [[ $tree_publication_status == published && ( -e $active || -L $active ) ]]; then + validate_tree_backup_if_present "$backup" + return 0 + fi + # A pending state with this transition's owned backup proves that the old + # active tree was moved. Adopt any active tree that reappeared afterward, + # whether it is our completed swap or a user repair. + if [[ $tree_publication_status == pending && -e $active && -e $backup ]]; then + validate_tree_backup_if_present "$backup" + write_tree_state "$state_file" "$backup" "" "" published + return 0 + fi + if [[ -e $backup || -L $backup ]]; then + [[ ! -L $backup && -d $backup ]] || fail "The Quattro config backup is not an owned directory: $backup" + marker="$backup/.omarchy-upgrade-to-quattro-config-tree-backup-complete" + if [[ ! -e $marker ]]; then + [[ ! -e $active && ! -L $active ]] || fail "The Quattro config backup is not owned by this transition: $backup" + write_owned_marker "$marker" "$backup_suffix" + fi + [[ ! -L $marker ]] || fail "The Quattro config backup marker must not be a symlink: $marker" + owned_marker_matches_identity "$marker" || fail "The Quattro config backup marker has unexpected content: $marker" + if [[ -e $active || -L $active ]]; then + target_collision=$(config_backup_path "$backup") + mv "$active" "$target_collision" + fi + else + if [[ -e $active || -L $active ]]; then + mv "$active" "$backup" + write_owned_marker "$backup/.omarchy-upgrade-to-quattro-config-tree-backup-complete" "$backup_suffix" + fi + fi + + [[ ! -L $temporary ]] || fail "The Quattro config publication path must not be a symlink: $temporary" + [[ ! -e $temporary ]] || rm -rf "$temporary" + mkdir "$temporary" + if ! cp -a "$source/." "$temporary/"; then + rm -rf "$temporary" + fail "Could not publish the staged config tree; retry the Quattro upgrade." + fi + mv "$temporary" "$active" + write_tree_state "$state_file" "$backup" "" "" published +} + replace_hyprland_config() { - log "Replacing the Hyprland config with Quattro defaults" + local stage stage_complete hypr_backup omarchy_backup - if [[ -d $HOME/.config/hypr ]]; then - mv "$HOME/.config/hypr" "$HOME/.config/hypr.omarchy3.$backup_suffix.bak" + if [[ -f $HOME/.config/hypr/hyprland.lua ]]; then + log "Keeping the existing Quattro Hyprland config" + publish_config_transition adopted + return 0 fi - mkdir -p "$HOME/.config/hypr" "$HOME/.config/omarchy" - cp -R "$checkout/config/hypr/." "$HOME/.config/hypr/" - cp -R "$checkout/config/omarchy/." "$HOME/.config/omarchy/" + + stage="$HOME/.config.omarchy3.$backup_suffix.quattro-config.partial" + stage_complete="$stage/.omarchy-upgrade-to-quattro-config-complete" + hypr_backup="$HOME/.config/hypr.omarchy3.$backup_suffix.bak" + omarchy_backup="$HOME/.config/omarchy.omarchy3.$backup_suffix.bak" + + [[ ! -L $stage ]] || fail "The Quattro config stage must not be a symlink: $stage" + if [[ -e $stage_complete || -L $stage_complete ]]; then + [[ ! -L $stage_complete ]] || fail "The Quattro config completion marker must not be a symlink: $stage_complete" + owned_marker_matches_identity "$stage_complete" || + fail "The Quattro config completion marker has unexpected content: $stage_complete" + [[ ! -L $stage/hypr && ! -L $stage/omarchy && -f $stage/hypr/hyprland.lua && -d $stage/omarchy ]] || + fail "The Quattro config stage is incomplete: $stage" + else + [[ ! -e $stage ]] || rm -rf "$stage" + log "Preparing the Quattro Hyprland and Omarchy configs" + mkdir -p "$stage/hypr" "$stage/omarchy" + if [[ -d $HOME/.config/omarchy ]]; then + if ! cp -a "$HOME/.config/omarchy/." "$stage/omarchy/"; then + fail "Could not stage the existing Omarchy config; retry the Quattro upgrade." + fi + fi + if ! cp -a "$checkout/config/hypr/." "$stage/hypr/"; then + fail "Could not stage the Quattro Hyprland config; retry the Quattro upgrade." + fi + if ! cp -a "$checkout/config/omarchy/." "$stage/omarchy/"; then + fail "Could not stage the Quattro Omarchy config; retry the Quattro upgrade." + fi + write_owned_marker "$stage_complete" "$backup_suffix" + fi + + mkdir -p "$HOME/.config" + + # Publish Omarchy first and Hyprland last. The complete stage remains in + # place so a power loss between these two publications can resume after the + # exact-identity backup and publication paths are rediscovered from disk. + publish_config_tree omarchy "$stage/omarchy" "$HOME/.config/omarchy" "$omarchy_backup" "$HOME/.config.omarchy3.$backup_suffix.omarchy-publish.partial" "$omarchy_backup_state_file" + publish_config_tree hypr "$stage/hypr" "$HOME/.config/hypr" "$hypr_backup" "$HOME/.config.omarchy3.$backup_suffix.hypr-publish.partial" "$hypr_backup_state_file" + + [[ -d $HOME/.config/omarchy ]] || + fail "The Quattro Omarchy config was not published completely; retry the upgrade." + [[ -f $HOME/.config/hypr/hyprland.lua ]] || + fail "The Quattro Hyprland config was not published completely; retry the upgrade." + publish_config_transition published } run_quattro_setup() { diff --git a/docs/upgrade-to-quattro.md b/docs/upgrade-to-quattro.md index 575eded0fbf..181498bb2c4 100644 --- a/docs/upgrade-to-quattro.md +++ b/docs/upgrade-to-quattro.md @@ -34,7 +34,8 @@ retires the old one. - **The upgrade is one-way.** There is no supported downgrade to 3.x. - **Back up first.** At minimum, know that your macOS side is safe and copy anything irreplaceable off the Linux side. The script also backs up - `~/.config` and your Hyprland config before touching them. + `~/.config` and your Hyprland config before touching them. A retry reuses its + complete backup instead of replacing it with a later copy. - Your Hyprland configuration is replaced. 3.x used `hyprland.conf`; Quattro is configured in Lua. Your old files are moved aside, not deleted, but expect to redo personal keybindings and monitor tweaks in the new format. @@ -51,7 +52,11 @@ while — Quickshell and a few other AUR packages are compiled on the machine. ## What the script does -1. Backs up `~/.config` to `~/.config.omarchy3..bak`. +1. Backs up `~/.config` to an owned `~/.config.omarchy3.-.bak` + directory. The backup identity is retained under + `~/.local/state/omarchy/upgrade-to-quattro-mac/` so an interrupted retry + reuses the same complete backup; older or unrelated `.bak` directories are + left untouched. 2. Switches `~/.local/share/omarchy` to the `quattro` branch (it refuses to run if the checkout has uncommitted changes, and refuses to continue unless the checked-out tree is a Quattro one). @@ -64,14 +69,25 @@ while — Quickshell and a few other AUR packages are compiled on the machine. and `/usr/bin/omarchy-*` symlinks (Omarchy's systemd units start commands by that path). 5. Replaces the 3.x line in `~/.bashrc` with Quattro's two-line bootstrap. -6. Moves `~/.config/hypr` to a `.bak` and copies in the Quattro defaults. +6. Stages the Quattro `hypr` and `omarchy` defaults together, preserving + unrelated existing files under `~/.config/omarchy`, then publishes them with + the old directories retained under collision-safe `.bak` paths. If + `~/.config/hypr/hyprland.lua` already exists, the active config is adopted + as-is so a user repair is not replaced on retry; that Lua file is a + preservation signal, not proof that the complete Omarchy tree is present. 7. Runs `omarchy-setup-system --upgrade`, `omarchy-finalize-user`, and `omarchy-migrate`. 8. Removes the retired Waybar/Walker/Mako/SwayOSD desktop stack and switches networking from iwd to NetworkManager. -Re-running the script after a failure is safe — every step either skips work -that is already done or redoes it harmlessly. +Re-running the script after a failure is safe for the config transition — the +complete backup and any complete same-identity stage are reused, while partial +copies are rebuilt. A user-repaired active `hyprland.lua` is adopted without +recopying either config tree. The transition lock allows only one backup or +publication at a time, and an interrupted publication resumes from its +same-identity stage without overwriting an existing backup. Other upgrade steps +remain safe to retry as described by their individual commands; the state +record does not claim that the whole upgrade completed. ## After the upgrade diff --git a/test/shell.d/upgrade-to-quattro-mac-test.sh b/test/shell.d/upgrade-to-quattro-mac-test.sh index e307c258037..34ee7787f30 100755 --- a/test/shell.d/upgrade-to-quattro-mac-test.sh +++ b/test/shell.d/upgrade-to-quattro-mac-test.sh @@ -215,3 +215,644 @@ if (( installs_quickshell )); then fail "a failed quickshell install reports through fail(), not a silent set -e abort" pass "a failed quickshell install reports through fail()" fi + +# Exercise the config seam in temporary homes. These probes intentionally load +# only the config functions: running the complete upgrade would require an ARM +# machine, pacman, yay, sudo, and a live checkout. +function_source() { + awk -v name="$1" ' + $0 == name "() {" { inside = 1; depth = 1; next } + inside { + line = $0 + gsub(/\$\{[^}]*\}/, "", line) + opens = gsub(/{/, "{", line) + closes = gsub(/}/, "}", line) + depth += opens - closes + if (depth <= 0) exit + print + } + ' "$upgrade_to_quattro_mac" +} + +load_config_functions() { + local name + + for name in \ + acquire_config_transition_lock \ + is_valid_backup_identity \ + owned_marker_matches_identity \ + write_state_marker \ + write_owned_marker \ + load_or_create_backup_identity \ + backup_user_config \ + config_backup_path \ + publish_config_transition \ + tree_state_matches \ + write_tree_state \ + load_tree_state \ + ensure_tree_backup \ + validate_tree_backup_if_present \ + validate_target_backup_if_present \ + publish_config_tree \ + replace_hyprland_config; do + eval "$(printf '%s() {\n%s\n}' "$name" "$(function_source "$name")")" + done +} + +config_fixture() { + local root="$1" + + HOME="$root/home" + checkout="$root/checkout" + upgrade_state_dir="$HOME/.local/state/omarchy/upgrade-to-quattro-mac" + backup_identity_file="$upgrade_state_dir/backup-identity" + backup_absent_file="$upgrade_state_dir/config-backup-absent" + config_transition_file="$upgrade_state_dir/config-transition-complete" + config_transition_lock="$upgrade_state_dir/config-transition.lock" + hypr_backup_state_file="$upgrade_state_dir/hypr-backup-state" + omarchy_backup_state_file="$upgrade_state_dir/omarchy-backup-state" + backup_suffix="" + mkdir -p "$HOME/.config/hypr" "$HOME/.config/omarchy" "$checkout/config/hypr" "$checkout/config/omarchy" + printf 'legacy\n' >"$HOME/.config/hypr/hyprland.conf" + printf 'old omarchy\n' >"$HOME/.config/omarchy/user.conf" + printf 'quattro\n' >"$checkout/config/hypr/hyprland.lua" + printf 'shipped omarchy\n' >"$checkout/config/omarchy/default.conf" +} + +config_copy_and_retry_probe() ( + local root identity backup_root stale_backup + + root=$(mktemp -d) + trap 'rm -rf "$root"' EXIT + config_fixture "$root" + log() { :; } + flock() { return 0; } + load_config_functions + + # An old-style backup is not owned by this run and must remain untouched. + stale_backup="$HOME/.config.omarchy3.19990101000000.bak" + mkdir "$stale_backup" + printf 'stale\n' >"$stale_backup/old.conf" + + CP_FAIL=backup + cp() { + if [[ ${CP_FAIL:-} == backup && $* == *"$HOME/.config/."* ]]; then + command cp "$@" + CP_FAIL= + return 1 + fi + command cp "$@" + } + export CP_FAIL + if ( backup_user_config ) >/dev/null 2>&1; then + fail "backup copy failure unexpectedly succeeded" + fi + CP_FAIL=none + identity=$(<"$backup_identity_file") + [[ ! -e "$HOME/.config.omarchy3.$identity.bak.partial" ]] || fail "failed backup left a plausible partial backup" + backup_user_config + [[ $(<"$backup_identity_file") == "$identity" ]] || fail "backup retry allocated a second identity" + backup_root="$HOME/.config.omarchy3.$identity.bak" + [[ -f "$backup_root/.omarchy-upgrade-to-quattro-config-backup-complete" ]] || fail "backup retry did not publish its completion marker" + [[ -f "$stale_backup/old.conf" ]] || fail "unrelated backup was modified" + + replace_hyprland_config + [[ -f "$HOME/.config/hypr/hyprland.lua" ]] || fail "first config publication missed Hyprland" + [[ -f "$HOME/.config/omarchy/default.conf" ]] || fail "first config publication missed Omarchy" + [[ -f "$HOME/.config/omarchy/user.conf" ]] || fail "Omarchy stage dropped an unrelated user file" + + printf 'user repair\n' >"$HOME/.config/hypr/hyprland.lua" + printf 'user omarchy repair\n' >"$HOME/.config/omarchy/default.conf" + backup_user_config + replace_hyprland_config + grep -qx 'user repair' "$HOME/.config/hypr/hyprland.lua" || fail "user-repaired Hyprland config was replaced" + grep -qx 'user omarchy repair' "$HOME/.config/omarchy/default.conf" || fail "user-repaired Omarchy config was replaced" +) +config_copy_and_retry_probe +pass "config backup retries preserve identity and ignore unrelated backups" + +config_collision_probe() ( + local root base result + + root=$(mktemp -d) + trap 'rm -rf "$root"' EXIT + eval "$(printf '%s() {\n%s\n}' config_backup_path "$(function_source config_backup_path)")" + base="$root/hypr.omarchy3.20260901010101-ABC123.bak" + mkdir "$base" + result=$(config_backup_path "$base") + [[ $result == "$base.1" ]] || fail "timestamp collision did not select a unique backup path" "selected: $result" + [[ -d $base ]] || fail "timestamp collision removed the existing backup" +) +config_collision_probe +pass "timestamp collisions do not overwrite existing backups" + +config_stage_failure_probe() ( + local root + + root=$(mktemp -d) + trap 'rm -rf "$root"' EXIT + config_fixture "$root" + log() { :; } + flock() { return 0; } + load_config_functions + backup_user_config + CP_FAIL=stage + cp() { + if [[ ${CP_FAIL:-} == stage && $* == *"$checkout/config/hypr/."* ]]; then + CP_FAIL= + return 1 + fi + command cp "$@" + } + export CP_FAIL + if ( replace_hyprland_config ) >/dev/null 2>&1; then + fail "staged config copy failure unexpectedly succeeded" + fi + CP_FAIL=none + [[ -f "$HOME/.config/hypr/hyprland.conf" ]] || fail "staged copy failure replaced active Hyprland" + [[ ! -f "$config_transition_file" ]] || fail "staged copy failure marked transition complete" + replace_hyprland_config + [[ -f "$HOME/.config/hypr/hyprland.lua" ]] || fail "retry did not rebuild the failed stage" +) +config_stage_failure_probe +pass "staged config copy failure leaves active config intact for retry" + +config_publication_retry_probe() ( + local root repair_root="" identity + + root=$(mktemp -d) + trap 'rm -rf "$root" "$repair_root"' EXIT + config_fixture "$root" + log() { :; } + flock() { return 0; } + load_config_functions + backup_user_config + MV_FAIL=hypr + mv() { + if [[ ${MV_FAIL:-} == hypr && $2 == "$HOME/.config/hypr" ]]; then + MV_FAIL= + return 1 + fi + command mv "$@" + } + export MV_FAIL + if ( replace_hyprland_config ) >/dev/null 2>&1; then + fail "Hyprland publication failure unexpectedly succeeded" + fi + MV_FAIL=none + [[ -f "$HOME/.config/omarchy/default.conf" ]] || fail "Omarchy was not published before Hyprland" + identity=$(<"$backup_identity_file") + [[ -f "$HOME/.config/hypr.omarchy3.$identity.bak/hyprland.conf" ]] || fail "Hyprland was not preserved before its publication" + replace_hyprland_config + [[ -f "$HOME/.config/hypr/hyprland.lua" ]] || fail "retry did not resume the exact staged Hyprland tree" + + # A user can repair the active legacy directory before retrying; Lua is the + # adoption signal even if other legacy files remain. + repair_root=$(mktemp -d) + config_fixture "$repair_root" + backup_user_config + MV_FAIL=hypr + if ( replace_hyprland_config ) >/dev/null 2>&1; then + fail "repair setup publication failure unexpectedly succeeded" + fi + MV_FAIL=none + mkdir -p "$HOME/.config/hypr" + printf 'repaired lua\n' >"$HOME/.config/hypr/hyprland.lua" + printf 'repaired omarchy\n' >"$HOME/.config/omarchy/default.conf" + replace_hyprland_config + grep -qx 'repaired lua' "$HOME/.config/hypr/hyprland.lua" || fail "active repaired Hyprland was not adopted" + grep -qx 'repaired omarchy' "$HOME/.config/omarchy/default.conf" || fail "active repaired Omarchy was recopied" +) +config_publication_retry_probe +pass "partial publication resumes safely and adopts repaired active Hyprland" + +config_absent_retry_probe() ( + local root + + root=$(mktemp -d) + trap 'rm -rf "$root"' EXIT + HOME="$root/home" + upgrade_state_dir="$HOME/.local/state/omarchy/upgrade-to-quattro-mac" + backup_identity_file="$upgrade_state_dir/backup-identity" + backup_absent_file="$upgrade_state_dir/config-backup-absent" + config_transition_file="$upgrade_state_dir/config-transition-complete" + config_transition_lock="$upgrade_state_dir/config-transition.lock" + backup_suffix="" + log() { :; } + flock() { return 0; } + load_config_functions + + backup_user_config + [[ $(<"$backup_absent_file") == absent ]] || fail "missing ~/.config was not recorded" + mkdir -p "$HOME/.config" + printf 'created after absence\n' >"$HOME/.config/new.conf" + backup_user_config + [[ $(<"$backup_absent_file") == absent ]] || fail "absent marker changed on retry" + [[ ! -e "$HOME/.config.omarchy3.$backup_suffix.bak" ]] || fail "absent retry created a backup after the config appeared" +) +config_absent_retry_probe +pass "an absent-config marker remains authoritative on retry" + +config_lock_contention_probe() ( + local root + + root=$(mktemp -d) + trap 'rm -rf "$root"' EXIT + config_fixture "$root" + log() { :; } + flock() { return 1; } + load_config_functions + if ( backup_user_config ) >/dev/null 2>&1; then + fail "a held config transition lock was ignored" + fi + [[ ! -e "$backup_identity_file" ]] || fail "lock contention created a backup identity" + + rm -rf "$upgrade_state_dir" + mkdir -p "$HOME/.local/state" + ln -s "$root/foreign-state" "$upgrade_state_dir" + if ( backup_user_config ) >/dev/null 2>&1; then + fail "a symlinked state directory was accepted" + fi + rm -f "$upgrade_state_dir" + mkdir -p "$upgrade_state_dir" + ln -s "$backup_identity_file" "$config_transition_lock" + if ( backup_user_config ) >/dev/null 2>&1; then + fail "a symlinked transition lock was accepted" + fi +) +config_lock_contention_probe +pass "config transition lock contention prevents a second invocation" + +config_real_lock_probe() ( + local root script ready first_pid attempt lock_body + + if ! command -v flock >/dev/null 2>&1; then + printf 'skip - real two-process flock probe requires flock\n' + return 0 + fi + + root=$(mktemp -d) + trap 'rm -rf "$root"' EXIT + script=$(mktemp) + ready="$root/ready" + lock_body=$(function_source acquire_config_transition_lock) + printf '%s\n' '#!/bin/bash' 'set -e' 'fail() { exit 1; }' \ + "upgrade_state_dir=$root/state" \ + "config_transition_lock=$root/state/config-transition.lock" \ + 'acquire_config_transition_lock() {' \ + "$lock_body" \ + '}' \ + "acquire_config_transition_lock" \ + "touch $ready" \ + 'sleep 2' >"$script" + chmod +x "$script" + "$script" & + first_pid=$! + for ((attempt = 0; attempt < 100; attempt++)); do + [[ -e $ready ]] && break + sleep 0.02 + done + [[ -e $ready ]] || fail "the first flock holder did not acquire the lock" + if "$script"; then + kill "$first_pid" 2>/dev/null || true + wait "$first_pid" 2>/dev/null || true + fail "a second process acquired the config transition lock" + fi + wait "$first_pid" +) +config_real_lock_probe +if command -v flock >/dev/null 2>&1; then + pass "two production lock holders cannot overlap" +else + pass "real two-process flock probe skipped without flock" +fi + +state_marker_rejection_probe() ( + local root identity backup_root backup_marker stage stage_marker + + root=$(mktemp -d) + trap 'rm -rf "$root"' EXIT + config_fixture "$root" + log() { :; } + flock() { return 0; } + load_config_functions + + expect_backup_failure() { + if ( backup_user_config ) >/dev/null 2>&1; then + fail "a malformed Quattro backup marker was accepted" + fi + } + backup_user_config + identity=$(<"$backup_identity_file") + backup_root="$HOME/.config.omarchy3.$identity.bak" + backup_marker="$backup_root/.omarchy-upgrade-to-quattro-config-backup-complete" + + rm -rf "$backup_root" + mkdir "$backup_root" + printf 'foreign\n' >"$backup_marker" + expect_backup_failure + rm -rf "$backup_root" + mkdir "$backup_root" + ln -s "$backup_identity_file" "$backup_marker" + expect_backup_failure + + rm -f "$backup_marker" + printf '%s\n' "$identity" >"$backup_marker" + rm -f "$backup_identity_file" + ln -s "$backup_marker" "$backup_identity_file" + expect_backup_failure + + rm -f "$backup_identity_file" + printf '%s\n' "$identity" >"$backup_identity_file" + stage="$HOME/.config.omarchy3.$identity.quattro-config.partial" + stage_marker="$stage/.omarchy-upgrade-to-quattro-config-complete" + rm -rf "$stage" + mkdir -p "$stage/hypr" "$stage/omarchy" + printf 'foreign\n' >"$stage_marker" + rm -rf "$HOME/.config/hypr" + if ( replace_hyprland_config ) >/dev/null 2>&1; then + fail "a foreign stage marker was accepted" + fi + rm -f "$stage_marker" + ln -s "$backup_identity_file" "$stage_marker" + if ( replace_hyprland_config ) >/dev/null 2>&1; then + fail "a symlinked stage marker was accepted" + fi + + rm -rf "$stage" + mkdir -p "$HOME/.config/hypr" + printf 'repaired lua\n' >"$HOME/.config/hypr/hyprland.lua" + rm -f "$config_transition_file" + printf '19990101000000-ABC123\npublished\n' >"$config_transition_file" + if ( replace_hyprland_config ) >/dev/null 2>&1; then + fail "a wrong-content transition marker was accepted" + fi + rm -f "$config_transition_file" + ln -s "$backup_identity_file" "$config_transition_file" + if ( replace_hyprland_config ) >/dev/null 2>&1; then + fail "a symlinked transition marker was accepted" + fi +) +state_marker_rejection_probe +pass "foreign and symlinked identity markers are rejected" + +config_stage_shape_probe() ( + local root identity stage stage_marker + + root=$(mktemp -d) + trap 'rm -rf "$root"' EXIT + config_fixture "$root" + log() { :; } + flock() { return 0; } + load_config_functions + backup_user_config + identity=$(<"$backup_identity_file") + stage="$HOME/.config.omarchy3.$identity.quattro-config.partial" + stage_marker="$stage/.omarchy-upgrade-to-quattro-config-complete" + mkdir -p "$stage/hypr" "$stage/omarchy" + printf '%s\n' "$identity" >"$stage_marker" + rm -f "$HOME/.config/hypr/hyprland.conf" + if ( replace_hyprland_config ) >/dev/null 2>&1; then + fail "a completed stage with the wrong shape was accepted" + fi +) +config_stage_shape_probe +pass "completed stages must contain both owned config trees" + +mixed_hyprland_adoption_probe() ( + local root + + root=$(mktemp -d) + trap 'rm -rf "$root"' EXIT + config_fixture "$root" + log() { :; } + flock() { return 0; } + printf 'user Lua repair\n' >"$HOME/.config/hypr/hyprland.lua" + load_config_functions + backup_user_config + replace_hyprland_config + grep -qx 'user Lua repair' "$HOME/.config/hypr/hyprland.lua" || fail "mixed active Hyprland config was replaced" + grep -qx 'legacy' "$HOME/.config/hypr/hyprland.conf" || fail "mixed legacy Hyprland config was discarded" + grep -qx 'old omarchy' "$HOME/.config/omarchy/user.conf" || fail "mixed active Omarchy config was discarded" +) +mixed_hyprland_adoption_probe +pass "mixed Hyprland configs are adopted with both trees preserved" + +config_symlink_convergence_probe() ( + local root external hypr_target omarchy_target identity + + root=$(mktemp -d) + trap 'rm -rf "$root"' EXIT + config_fixture "$root" + external="$root/external" + hypr_target="$external/hypr-target" + omarchy_target="$external/omarchy-target" + mkdir -p "$hypr_target" "$omarchy_target" + printf 'external legacy hypr\n' >"$hypr_target/hyprland.conf" + printf 'external legacy omarchy\n' >"$omarchy_target/user.conf" + rm -rf "$HOME/.config/hypr" "$HOME/.config/omarchy" + ln -s "$hypr_target" "$HOME/.config/hypr" + ln -s "$omarchy_target" "$HOME/.config/omarchy" + log() { :; } + flock() { return 0; } + load_config_functions + backup_user_config + identity=$(<"$backup_identity_file") + replace_hyprland_config + [[ -L "$HOME/.config/hypr" && $(readlink "$HOME/.config/hypr") == "$hypr_target" ]] || fail "Hyprland symlink was replaced" + [[ -L "$HOME/.config/omarchy" && $(readlink "$HOME/.config/omarchy") == "$omarchy_target" ]] || fail "Omarchy symlink was replaced" + [[ -f "$HOME/.config/hypr.omarchy3.$identity.bak/hyprland.conf" ]] || fail "dereferenced Hyprland target was not backed up" + [[ -f "$HOME/.config/omarchy.omarchy3.$identity.bak/user.conf" ]] || fail "dereferenced Omarchy target was not backed up" + [[ -f "$hypr_target.omarchy3.$identity.bak/hyprland.conf" ]] || fail "external Hyprland target backup was not retained" + [[ -f "$omarchy_target.omarchy3.$identity.bak/user.conf" ]] || fail "external Omarchy target backup was not retained" + [[ -f "$hypr_target/hyprland.lua" && -f "$omarchy_target/default.conf" ]] || fail "symlink targets did not converge to Quattro config" +) +config_symlink_convergence_probe +pass "legacy config symlinks remain links while their targets converge safely" + +config_symlink_dangling_retry_probe() ( + local root external hypr_target omarchy_target identity target_backup marker fail_marker_path + + root=$(mktemp -d) + trap 'rm -rf "$root"' EXIT + config_fixture "$root" + external="$root/external" + hypr_target="$external/hypr-target" + omarchy_target="$external/omarchy-target" + mkdir -p "$hypr_target" "$omarchy_target" + printf 'external legacy hypr\n' >"$hypr_target/hyprland.conf" + printf 'external legacy omarchy\n' >"$omarchy_target/user.conf" + rm -rf "$HOME/.config/hypr" "$HOME/.config/omarchy" + ln -s "$hypr_target" "$HOME/.config/hypr" + ln -s "$omarchy_target" "$HOME/.config/omarchy" + log() { :; } + flock() { return 0; } + load_config_functions + backup_user_config + identity=$(<"$backup_identity_file") + target_backup="$(readlink -f "$hypr_target").omarchy3.$identity.bak" + fail_marker_path="$target_backup/.omarchy-upgrade-to-quattro-target-backup-complete" + eval "$(printf '%s() {\n%s\n}' write_owned_marker_impl "$(function_source write_owned_marker)")" + write_owned_marker() { + if [[ $1 == "$fail_marker_path" ]]; then + exit 77 + fi + write_owned_marker_impl "$@" + } + if ( replace_hyprland_config ); then + fail "dangling symlink interruption unexpectedly succeeded" + fi + marker="$target_backup/.omarchy-upgrade-to-quattro-target-backup-complete" + [[ -L "$HOME/.config/hypr" && $(readlink "$HOME/.config/hypr") == "$hypr_target" ]] || fail "dangling interruption replaced Hyprland link" + [[ ! -e "$hypr_target" && -d "$target_backup" && ! -e "$marker" ]] || fail "dangling interruption did not leave the recorded target boundary" "target=$(ls -ld "$hypr_target" 2>&1); backup=$(ls -ld "$target_backup" 2>&1); marker=$(ls -l "$marker" 2>&1)" + [[ $(readlink -f "$HOME/.config/hypr") == "$(sed -n '3p' "$hypr_backup_state_file")" ]] || fail "readlink -f did not resolve the recorded dangling symlink" "link=$(readlink -f "$HOME/.config/hypr"); recorded=$(sed -n '3p' "$hypr_backup_state_file")" + load_config_functions + replace_hyprland_config + [[ -L "$HOME/.config/hypr" && -f "$hypr_target/hyprland.lua" ]] || fail "dangling symlink retry did not converge" + [[ -f "$target_backup/hyprland.conf" ]] || fail "symlink publication retry discarded external legacy Hyprland content" +) +config_symlink_dangling_retry_probe +pass "dangling symlink publication retries from its recorded boundary" + +config_symlink_published_retry_probe() ( + local root external hypr_target omarchy_target identity target_backup + + root=$(mktemp -d) + trap 'rm -rf "$root"' EXIT + config_fixture "$root" + external="$root/external" + hypr_target="$external/hypr-target" + omarchy_target="$external/omarchy-target" + mkdir -p "$hypr_target" "$omarchy_target" + printf 'external legacy hypr\n' >"$hypr_target/hyprland.conf" + printf 'external legacy omarchy\n' >"$omarchy_target/user.conf" + rm -rf "$HOME/.config/hypr" "$HOME/.config/omarchy" + ln -s "$hypr_target" "$HOME/.config/hypr" + ln -s "$omarchy_target" "$HOME/.config/omarchy" + log() { :; } + flock() { return 0; } + load_config_functions + backup_user_config + identity=$(<"$backup_identity_file") + target_backup="$hypr_target.omarchy3.$identity.bak" + eval "$(printf '%s() {\n%s\n}' write_tree_state_impl "$(function_source write_tree_state)")" + write_tree_state() { + if [[ $1 == "$hypr_backup_state_file" && $5 == published ]]; then + exit 78 + fi + write_tree_state_impl "$@" + } + if ( replace_hyprland_config ) >/dev/null 2>&1; then + fail "post-swap state interruption unexpectedly succeeded" + fi + [[ -f "$hypr_target/hyprland.lua" ]] || fail "post-swap interruption did not leave the published target" + [[ -f "$target_backup/.omarchy-upgrade-to-quattro-target-backup-complete" ]] || fail "post-swap interruption lost the owned target backup" + load_config_functions + publish_config_tree hypr "$HOME/.config.omarchy3.$identity.quattro-config.partial/hypr" "$HOME/.config/hypr" "$HOME/.config/hypr.omarchy3.$identity.bak" "$HOME/.config.omarchy3.$identity.hypr-publish.partial" "$hypr_backup_state_file" + [[ -f "$hypr_target/hyprland.lua" && -f "$target_backup/hyprland.conf" ]] || fail "published target retry changed the target or lost its backup" + [[ ! -e "$target_backup.1" ]] || fail "published target retry allocated another target backup" +) +config_symlink_published_retry_probe +pass "completed symlink publication is adopted from owned-backup state" + +config_symlink_repair_after_move_probe() ( + local root external hypr_target hypr_target_canonical omarchy_target identity target_backup repair_temp_path + + root=$(mktemp -d) + trap 'rm -rf "$root"' EXIT + config_fixture "$root" + external="$root/external" + hypr_target="$external/hypr-target" + omarchy_target="$external/omarchy-target" + mkdir -p "$hypr_target" "$omarchy_target" + hypr_target_canonical="$(readlink -f "$hypr_target")" + printf 'external legacy hypr\n' >"$hypr_target/hyprland.conf" + printf 'external legacy omarchy\n' >"$omarchy_target/user.conf" + rm -rf "$HOME/.config/hypr" "$HOME/.config/omarchy" + ln -s "$hypr_target" "$HOME/.config/hypr" + ln -s "$omarchy_target" "$HOME/.config/omarchy" + log() { :; } + flock() { return 0; } + load_config_functions + backup_user_config + identity=$(<"$backup_identity_file") + target_backup="$hypr_target.omarchy3.$identity.bak" + repair_temp_path="$(readlink -f "$external")/.omarchy3.$identity.hypr-publish.partial" + mv() { + if [[ $1 == "$repair_temp_path" && $2 == "$hypr_target_canonical" ]]; then + exit 79 + fi + command mv "$@" + } + if ( replace_hyprland_config ) >/dev/null 2>&1; then + fail "symlink repair boundary unexpectedly succeeded" + fi + [[ -L "$HOME/.config/hypr" && ! -e "$hypr_target" && -f "$target_backup/.omarchy-upgrade-to-quattro-target-backup-complete" ]] || fail "symlink repair boundary was not staged after the target move" + mkdir "$hypr_target" + printf 'user repaired target\n' >"$hypr_target/repaired.conf" + load_config_functions + publish_config_tree hypr "$HOME/.config.omarchy3.$identity.quattro-config.partial/hypr" "$HOME/.config/hypr" "$HOME/.config/hypr.omarchy3.$identity.bak" "$HOME/.config.omarchy3.$identity.hypr-publish.partial" "$hypr_backup_state_file" + grep -qx 'user repaired target' "$hypr_target/repaired.conf" || fail "repaired symlink target was replaced" + [[ ! -e "$target_backup.1" ]] || fail "symlink repair allocated another target backup" +) +config_symlink_repair_after_move_probe +pass "repaired symlink targets are preserved after the target move" + +config_regular_repair_after_move_probe() ( + local root identity backup_root temporary + + root=$(mktemp -d) + trap 'rm -rf "$root"' EXIT + config_fixture "$root" + log() { :; } + flock() { return 0; } + load_config_functions + backup_user_config + identity=$(<"$backup_identity_file") + backup_root="$HOME/.config/hypr.omarchy3.$identity.bak" + temporary="$HOME/.config.omarchy3.$identity.hypr-publish.partial" + mv() { + if [[ $1 == "$temporary" && $2 == "$HOME/.config/hypr" ]]; then + exit 80 + fi + command mv "$@" + } + if ( replace_hyprland_config ) >/dev/null 2>&1; then + fail "regular repair boundary unexpectedly succeeded" + fi + [[ ! -e "$HOME/.config/hypr" && -f "$backup_root/.omarchy-upgrade-to-quattro-config-tree-backup-complete" ]] || fail "regular repair boundary was not staged after the active move" + mkdir "$HOME/.config/hypr" + printf 'user repaired regular tree\n' >"$HOME/.config/hypr/repaired.conf" + load_config_functions + publish_config_tree hypr "$HOME/.config.omarchy3.$identity.quattro-config.partial/hypr" "$HOME/.config/hypr" "$backup_root" "$temporary" "$hypr_backup_state_file" + grep -qx 'user repaired regular tree' "$HOME/.config/hypr/repaired.conf" || fail "repaired regular tree was replaced" + [[ ! -e "$backup_root.1" ]] || fail "regular repair allocated another backup" +) +config_regular_repair_after_move_probe +pass "repaired regular config trees are preserved after the active move" + +config_tree_ownership_probe() ( + local root identity stale_backup backup_count + + root=$(mktemp -d) + trap 'rm -rf "$root"' EXIT + config_fixture "$root" + log() { :; } + flock() { return 0; } + load_config_functions + identity=20260901203000-ABC123 + mkdir -p "$upgrade_state_dir" + stale_backup="$HOME/.config/hypr.omarchy3.$identity.bak" + mkdir "$stale_backup" + printf 'unowned\n' >"$stale_backup/stale.conf" + printf '%s\n' "$identity" >"$backup_identity_file" + backup_user_config + replace_hyprland_config + [[ -f "$stale_backup/stale.conf" ]] || fail "unowned same-identity backup was overwritten" + [[ -f "$HOME/.config/hypr.omarchy3.$identity.bak.1/hyprland.conf" ]] || fail "same-identity backup collision was not avoided" + backup_count=$(find "$HOME" -maxdepth 1 -name "hypr.omarchy3.$identity.bak*" | wc -l) + replace_hyprland_config + [[ $(find "$HOME" -maxdepth 1 -name "hypr.omarchy3.$identity.bak*" | wc -l) == "$backup_count" ]] || fail "completed retry created an extra Hyprland backup" +) +config_tree_ownership_probe +pass "per-tree backup ownership is identity-bound and completed retries are stable"