Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions lib/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,36 @@ _omacase_zfuncdir() {
[ -n "$bindir" ] && printf '%s\n' "${bindir%/bin}/share/zsh/site-functions"
}

# --- convergence ledger -------------------------------------------------------
# Required steps that fail are recorded (not fatal) so independent work
# continues; the entry point reports partial convergence and exits nonzero.
OMACASE_INCOMPLETE=()

# require <label> <cmd...> — run a REQUIRED convergence step; on failure, warn
# and record. Optional steps keep using plain `run … || warn` and never ledger.
require() {
local label="$1"; shift
if ! run "$@"; then
warn "$label failed — continuing with remaining steps."
OMACASE_INCOMPLETE+=("$label")
return 0
fi
}

converged() { # converged "<verb phrase>" — final report + exit status
if [ "${#OMACASE_INCOMPLETE[@]}" -eq 0 ]; then
success "$1"
return 0
fi
warn "PARTIAL: $1 — ${#OMACASE_INCOMPLETE[@]} required step(s) failed:"
local s
for s in "${OMACASE_INCOMPLETE[@]}"; do
warn " - $s"
done
warn "Re-run \`omacase update\` after fixing the above."
return 1
}

# --- dry run -----------------------------------------------------------------
# Set OMACASE_DRYRUN=1 to print mutating commands instead of running them.
# Wrap every side-effecting command (brew, ln, defaults, services…)
Expand Down
16 changes: 10 additions & 6 deletions lib/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ omacase_install() {

step "1/8 Packages & apps (brew bundle)"
_brew_trust_declared_third_party
run brew bundle --file="$OMACASE_ROOT/Brewfile" \
|| warn "Some brew items failed; re-run later."
require "brew bundle" brew bundle --file="$OMACASE_ROOT/Brewfile"

step "2/8 Link \`omacase\` onto PATH + shell completion"
_link_command
Expand Down Expand Up @@ -52,11 +51,17 @@ omacase_install() {
_migrations_baseline

step "Done"
success "omacase installed."
warn "Next: run \`omacase doctor\` and grant Accessibility to OmniWM"
warn " (plus Automation → System Events so themes can sync macOS Light/Dark)."
warn "OmniWM also requires Displays have separate Spaces; a logout applies that setting."
warn "Don't like the result? \`omacase restore\` rolls back to the pre-install snapshot."
# When update nests install, the outer converged reports. Returning here
# keeps the function a simple command so set -e still applies inside
# (a `fn || true` call would disable errexit for _auto_backup etc.).
if [ -n "${OMACASE_NESTED_INSTALL:-}" ]; then
return 0
fi
converged "omacase installed"
}

# Homebrew requires an explicit trust decision before loading third-party
Expand All @@ -82,7 +87,7 @@ _brew_trust_declared_third_party() {
# mise is provided by `brew bundle` and activated in dot_zshrc.
_mise_install() {
have mise || { warn "mise not found (brew bundle should install it) — skipping npm CLIs."; return 0; }
run mise install -y || warn "mise install had issues — re-run \`mise install\` later."
require "mise install" mise install -y
}

# Grok CLI (xAI) ships as a self-updating native binary that installs into
Expand Down Expand Up @@ -150,8 +155,7 @@ _herdr_integrations() {
local agent
for agent in claude codex opencode pi grok; do
have "$agent" || continue
run herdr integration install "$agent" \
|| warn "herdr integration install $agent failed; re-run \`omacase update\`."
require "herdr integration ($agent)" herdr integration install "$agent"
done
}

Expand Down
16 changes: 10 additions & 6 deletions lib/update.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,27 @@ omacase_update() {
fi
fi
step "Updating Homebrew"
run brew update || true
require "brew update" brew update
source "$OMACASE_ROOT/lib/install.sh"
# Nested so install does not call converged (the outer report does). This is a
# simple command: set -e stays in effect inside omacase_install. Ledgered
# require() failures still return 0 and later upgrade steps still run.
OMACASE_NESTED_INSTALL=1
omacase_install
# One-time imperative cleanup the declarative apply can't do (e.g. uninstall a
# dropped cask). Idempotent + tracked; failure halts migrations but not update.
# dropped cask). Idempotent + tracked; failure retries on the next update.
source "$OMACASE_ROOT/lib/migrate.sh"
omacase_migrate || warn "Some migrations did not complete — they'll retry next update."
omacase_migrate || OMACASE_INCOMPLETE+=("migrations")
if [ -n "${OMACASE_SKIP_MISE_UPGRADE:-}" ]; then
info "Skipping mise tool upgrades (OMACASE_SKIP_MISE_UPGRADE is set)."
elif have mise; then
step "Upgrading mise tools (node + npm CLIs)"
warn "mise tools include npm packages pinned to latest; set OMACASE_SKIP_MISE_UPGRADE=1 to skip."
run mise upgrade || warn "mise upgrade had issues." # bumps latest-pinned npm CLIs
require "mise upgrade" mise upgrade
fi
step "Upgrading outdated formulae & casks"
run brew upgrade || warn "Some upgrades failed."
success "omacase up to date."
require "brew upgrade" brew upgrade
converged "omacase up to date"
}

# `omacase outdated` — print the number of outdated Homebrew packages.
Expand Down
196 changes: 196 additions & 0 deletions tests/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,197 @@ test_restore_absent_leaf_prunes_empty_parents_not_config() {
[ -d "$HOME/.config" ]
}

test_require_ledgers_failure_and_continues() {
(
# shellcheck source=/dev/null
source "$ROOT/lib/common.sh"
boom() { return 1; }
require "step-a" boom >/dev/null 2>&1
require "step-b" true >/dev/null 2>&1
[ "${#OMACASE_INCOMPLETE[@]}" -eq 1 ] && ! converged "done" >/dev/null 2>&1
)
}

# Keep install/update integration tests offline and off the live machine.
_test_stub_convergence_externals() {
brew() { return 0; }
mise() { return 0; }
herdr() { return 0; }
defaults() { return 0; }
killall() { return 0; }
osascript() { return 0; }
launchctl() { return 0; }
git() { return 0; }
bash() {
case "${1:-}" in
*/macos/defaults.sh) return 0 ;;
*) command bash "$@" ;;
esac
}
source() {
case "${1:-}" in
*/backup.sh)
builtin source "$@"
_preflight_command_links() { return 0; }
return 0 ;;
*/theme.sh)
omacase_theme() { return 0; }
can_set_appearance() { return 0; }
return 0 ;;
*/wm.sh)
omacase_wm() { return 0; }
return 0 ;;
*/migrate.sh)
_migrations_baseline() { return 0; }
omacase_migrate() { return 0; }
return 0 ;;
*/install.sh)
omacase_install() { return 0; }
return 0 ;;
*) builtin source "$@" ;;
esac
}
_link_command() { return 0; }
}

test_partial_brew_bundle_fails_install() {
local tmp out
tmp="$(mktemp -d)"
out="$tmp/out"
HOME="$tmp/home"
OMACASE_STATE="$tmp/state"
OMACASE_DATA="$tmp/data"
OMACASE_ROOT="$ROOT"
mkdir -p "$HOME"
(
# shellcheck source=/dev/null
source "$ROOT/lib/common.sh"
# shellcheck source=/dev/null
source "$ROOT/lib/install.sh"
_test_stub_convergence_externals
brew() { [ "${1:-}" = bundle ] && return 1; return 0; }
omacase_install
) >"$out" 2>&1
# shellcheck disable=SC2181 # status is intentionally captured after the subshell
[ $? -ne 0 ] && grep -q 'PARTIAL' "$out" && grep -q 'brew bundle' "$out"
}

test_partial_brew_upgrade_fails_update() {
local tmp out
tmp="$(mktemp -d)"
out="$tmp/out"
HOME="$tmp/home"
OMACASE_STATE="$tmp/state"
OMACASE_DATA="$tmp/data"
OMACASE_ROOT="$ROOT"
mkdir -p "$HOME"
(
export OMACASE_UPDATE_REEXECED=1
# shellcheck source=/dev/null
source "$ROOT/lib/common.sh"
# shellcheck source=/dev/null
source "$ROOT/lib/update.sh"
_test_stub_convergence_externals
brew() { [ "${1:-}" = upgrade ] && return 1; return 0; }
omacase_update
) >"$out" 2>&1
# shellcheck disable=SC2181 # status is intentionally captured after the subshell
[ $? -ne 0 ] && grep -q 'PARTIAL' "$out" && grep -q 'brew upgrade' "$out"
}

test_partial_brew_update_fails_update() {
local tmp out
tmp="$(mktemp -d)"
out="$tmp/out"
HOME="$tmp/home"
OMACASE_STATE="$tmp/state"
OMACASE_DATA="$tmp/data"
OMACASE_ROOT="$ROOT"
mkdir -p "$HOME"
(
export OMACASE_UPDATE_REEXECED=1
# shellcheck source=/dev/null
source "$ROOT/lib/common.sh"
# shellcheck source=/dev/null
source "$ROOT/lib/update.sh"
_test_stub_convergence_externals
brew() { [ "${1:-}" = update ] && return 1; return 0; }
omacase_update
) >"$out" 2>&1
# shellcheck disable=SC2181 # status is intentionally captured after the subshell
[ $? -ne 0 ] && grep -q 'PARTIAL' "$out" && grep -q 'brew update' "$out"
}

# `omacase_install || true` disables errexit inside install, so a failed
# _auto_backup would still link dotfiles. Nested install must stay a simple
# command so set -e still applies to non-ledgered steps.
test_update_stops_on_non_ledgered_install_failure() {
local tmp out rc
tmp="$(mktemp -d)"
out="$tmp/out"
mkdir -p "$tmp/home"
rc=0
TEST_REPO_ROOT="$ROOT" TEST_TMP="$tmp" \
HOME="$tmp/home" OMACASE_STATE="$tmp/state" OMACASE_DATA="$tmp/data" \
OMACASE_ROOT="$ROOT" bash -e -u -o pipefail -c '
export OMACASE_UPDATE_REEXECED=1
builtin source "$TEST_REPO_ROOT/lib/common.sh"
source() {
case "${1:-}" in
*/backup.sh)
builtin source "$@"
_preflight_command_links() { return 0; }
_auto_backup() {
false
: > "$TEST_TMP/backup-continued"
}
_link_dotfiles() { : > "$TEST_TMP/dotfiles-ran"; }
return 0 ;;
*/install.sh)
builtin source "$@"
_link_command() { return 0; }
return 0 ;;
*/theme.sh)
omacase_theme() { return 0; }
can_set_appearance() { return 0; }
return 0 ;;
*/wm.sh)
omacase_wm() { return 0; }
return 0 ;;
*/migrate.sh)
_migrations_baseline() { return 0; }
omacase_migrate() { return 0; }
return 0 ;;
*) builtin source "$@" ;;
esac
}
brew() { return 0; }
mise() { return 0; }
herdr() { return 0; }
defaults() { return 0; }
killall() { return 0; }
osascript() { return 0; }
launchctl() { return 0; }
git() { return 0; }
bash() {
case "${1:-}" in
*/macos/defaults.sh) return 0 ;;
*) command bash "$@" ;;
esac
}
ensure_brew_env() { return 0; }
# shellcheck source=/dev/null
builtin source "$TEST_REPO_ROOT/lib/update.sh"
omacase_update
' >"$out" 2>&1
rc=$?
[ "$rc" -ne 0 ] &&
! grep -q 'omacase up to date' "$out" &&
[ ! -e "$tmp/backup-continued" ] &&
[ ! -e "$tmp/dotfiles-ran" ] &&
! grep -q 'omacase_install ||' "$ROOT/lib/update.sh"
}

test_update_fails_when_self_pull_fails() {
# omacase_update's ensure_brew_env aborts before the pull on anything but
# Apple Silicon + /opt/homebrew — the abort message would satisfy the
Expand Down Expand Up @@ -947,6 +1138,11 @@ run_test "restore PRESENT leaf preserves sibling" test_restore_present_leaf_pres
run_test "restore legacy PRESENT dir does not follow current symlink" test_restore_legacy_present_dir_does_not_follow_current_symlink
run_test "restore legacy ABSENT dir preserves sibling" test_restore_legacy_absent_dir_preserves_sibling
run_test "restore ABSENT leaf prunes empty parents not .config" test_restore_absent_leaf_prunes_empty_parents_not_config
run_test "require ledgers a failure and continues" test_require_ledgers_failure_and_continues
run_test "partial brew bundle fails install" test_partial_brew_bundle_fails_install
run_test "partial brew upgrade fails update" test_partial_brew_upgrade_fails_update
run_test "partial brew update fails update" test_partial_brew_update_fails_update
run_test "update stops on non-ledgered install failure" test_update_stops_on_non_ledgered_install_failure
run_test "update fails on self-update failure" test_update_fails_when_self_pull_fails
run_test "backup domains cover macos/defaults.sh" test_backup_domains_cover_defaults_sh
run_test "defaults disable Stage Manager" test_stage_manager_is_disabled_by_defaults
Expand Down