#!/usr/bin/env bash # deploy.sh — stow-equivalent deployment of the dotfiles/ packages in this repo. # # Model: the repo checkout IS the deployment. Packages are home-relative trees # under dotfiles//; deploying replaces live files with symlinks into the # repo so `git pull` + `deploy.sh` is the whole propagation story. # # Modes: # deploy.sh sync (default): adopt identicals, link new, quarantine-and-replace divergents # deploy.sh --check dry-run report only; writes nothing # deploy.sh --adopt like sync but divergents are reported and SKIPPED, not quarantined # # Host divergence: a package named zz-$(hostname) is stowed LAST with override # semantics (its files replace symlinks from base packages). # # Special cases: # - dconf package: dump.txt is loaded via `dconf load /` instead of symlinked. # - systemd-user: after linking, daemon-reload + enable --now units whose # ExecStart binary resolves on THIS machine (silent no-op elsewhere). set -euo pipefail REPO="$(git -C "$(dirname "$(realpath "$0")")" rev-parse --show-toplevel 2>/dev/null || true)" REPO="${DEPLOY_REPO:-$REPO}" PKGS_DIR="$REPO/dotfiles" HOST="${DEPLOY_HOST:-$(hostname)}" QUAR="$REPO/.quarantine" MODE="sync" case "${1:-}" in --check) MODE="check" ;; --adopt) MODE="adopt" ;; ""|sync) MODE="sync" ;; *) echo "usage: deploy.sh [--check|--adopt|sync]" >&2; exit 2 ;; esac [ -d "$PKGS_DIR" ] || { echo "deploy.sh: no dotfiles/ under $REPO" >&2; exit 1; } # packages = all dirs in dotfiles/, override package last mapfile -t PKGS < <(find "$PKGS_DIR" -maxdepth 1 -mindepth 1 -type d ! -name "zz-*" | sort) OVR="$PKGS_DIR/zz-$HOST" [ -d "$OVR" ] && PKGS+=("$OVR") actions=0; quarantined=0; skipped=0 deploy_file() { # $1 = repo file path, $2 = home-relative target path local src="$1" tgt="$HOME/$2" # Generated theme output is never deployment state: noctalia rewrites these files in # place, so a tracked copy is a stale snapshot and syncing one reverts a live palette to # the colours of the day it was committed. Classes are those the census measures by # mtime — see omp-docs/inventory/home-config-census.md, "Generated palette expansion". case "$2" in */themes/noctalia*|*/colors/noctalia*|*/noctalia.*|*/noctalia-*|*/Noctalia*) echo "SKIP $2 (generated theme output)"; skipped=$((skipped+1)); return 0 ;; esac if [ -L "$tgt" ]; then if [ "$(realpath "$tgt")" = "$(realpath "$src")" ]; then return 0; fi # already deployed case "$MODE" in check) echo "RELINK $2 (managed symlink changing owners)" ;; adopt) echo "SKIP $2 (foreign symlink, --adopt does not overwrite)" ; skipped=$((skipped+1)) ;; sync) if [[ "$(realpath "$tgt")" == "$REPO/"* ]]; then # managed file changing owners between packages: silent relink, nothing to quarantine ln -sfn "$(realpath "$src")" "$tgt"; actions=$((actions+1)) else ts="$(date +%Y%m%d-%H%M%S)-$$" dest="$QUAR/$HOST/$ts/$2" mkdir -p "$(dirname "$dest")" mv -- "$tgt" "$dest" ln -sfn "$(realpath "$src")" "$tgt" actions=$((actions+1)); quarantined=$((quarantined+1)) echo "QUAR $2 -> .quarantine/$HOST/$ts/$2" fi ;; esac return 0 fi if [ -e "$tgt" ]; then if cmp -s "$src" "$tgt"; then if [ "$MODE" = "check" ]; then echo "ADOPT $2 (identical, will symlink)"; return 0; fi ln -sfn "$(realpath "$src")" "$tgt"; actions=$((actions+1)); return 0 fi case "$MODE" in check) echo "DRIFT $2 (differs from repo; sync would quarantine)" ;; adopt) echo "SKIP $2 (divergent, --adopt does not overwrite)" ; skipped=$((skipped+1)) ;; sync) ts="$(date +%Y%m%d-%H%M%S)-$$" dest="$QUAR/$HOST/$ts/$2" mkdir -p "$(dirname "$dest")" mv -- "$tgt" "$dest" ln -sfn "$(realpath "$src")" "$tgt" actions=$((actions+1)); quarantined=$((quarantined+1)) echo "QUAR $2 -> .quarantine/$HOST/$ts/$2" ;; esac return 0 fi if [ "$MODE" = "check" ]; then echo "LINK $2 (missing, will symlink)"; return 0; fi mkdir -p "$(dirname "$tgt")" ln -sfn "$(realpath "$src")" "$tgt"; actions=$((actions+1)) } for pkg in "${PKGS[@]}"; do pkgname="$(basename "$pkg")" # dconf package: load dump instead of symlinking if [ "$pkgname" = "dconf" ] && [ -f "$pkg/dump.txt" ]; then if [ "$MODE" != "check" ]; then if command -v dconf >/dev/null; then dconf load / < "$pkg/dump.txt" && echo "LOAD $pkgname/dump.txt -> dconf"; fi else echo "LOAD $pkgname/dump.txt (dconf load / would run on sync)" fi continue fi while IFS= read -r -d '' f; do rel="${f#"$pkg"/}" # override packages nest base-package dirs internally: strip that segment for targets if [ "${pkgname#zz-}" != "$pkgname" ]; then case "$rel" in */*) [ -d "$PKGS_DIR/${rel%%/*}" ] && rel="${rel#*/}";; esac; fi deploy_file "$f" "$rel" done < <(find "$pkg" \( -type f -o -type l \) ! -name 'dump.txt' ! -path '*/.AGENTTRASH/*' -print0) done if [ "$MODE" != "check" ] && [ -d "$PKGS_DIR/systemd-user/.config/systemd/user" ]; then systemctl --user daemon-reload || true UNITS_DIR="$PKGS_DIR/systemd-user/.config/systemd/user" # Services AND timers. A timer is the schedule that makes a oneshot service run at all, and it # carries no ExecStart of its own — the binary that decides whether the unit belongs on this # box lives in the service it activates. Walking *.service only deployed live-sync.service on # every rebuild and never armed live-sync.timer: the heartbeat was present and never ticked. for unit in "$UNITS_DIR"/*.service "$UNITS_DIR"/*.timer; do [ -e "$unit" ] || continue name="$(basename "$unit")" probe="$unit" if [ "${name##*.}" = timer ]; then act="$(sed -n 's/^Unit=\(.*\)$/\1/p' "$unit" | head -1)" probe="$UNITS_DIR/${act:-"${name%.timer}.service"}" fi bin="$(sed -n 's/^ExecStart=\([^ ]*\).*/\1/p' "$probe" 2>/dev/null | head -1)" # systemd expands specifiers inside ExecStart; this shell does not. Matching the literal # `%h/…` makes a unit of THIS box look foreign and skips it in silence — which is exactly # how live-sync.service, the repo's own heartbeat, has been reported "absent on this # host" on every machine that deployed it. Expand what a user unit can carry; leave any # other specifier unmatched rather than guess at it. Refs #44. bin="${bin//%h/$HOME}" bin="${bin//%u/$(id -un)}" bin="${bin//%U/$(id -u)}" if [ -n "$bin" ] && command -v "$bin" >/dev/null 2>&1; then # Read the state, not the exit status: `is-enabled` exits 1 for a `linked` unit exactly # as for a `disabled` one. Starting something already installed is how a deploy locks a # live session — swayidle-lock sits enabled/inactive on blankpad and `--now` arms it # mid-conversation. Only a unit nothing has installed yet gets started. Refs #44. state="$(systemctl --user is-enabled "$name" 2>/dev/null || true)" if ! grep -q '^\[Install\]' "$unit"; then # systemd refuses to enable these: no [Install] section means nothing can want the unit # at login, and live-sync.service has none on purpose — its timer pulls it in. The old # branch swallowed that failure and printed ENABLE anyway, so the one line a rebuild is # checked by was false on every box. The section is readable right here, so do not make # the call at all. Refs #60. echo "INERT $name (state=${state:-none}; no [Install] section — another unit arms it, enable cannot)" elif [ -z "$state" ] || [ "$state" = disabled ]; then if systemctl --user enable --now "$name"; then echo "ENABLE $name (ExecStart resolves here; nothing running it yet)" else echo "INERT $name (enable --now failed — systemd's message above says why)" fi elif systemctl --user enable "$name"; then echo "ENABLE $name (already $state; not started — a live session decides what runs)" else echo "INERT $name (already $state; enable failed — systemd's message above says why)" fi else echo "SKIP $name (ExecStart '$bin' absent on this host)" fi done fi # keep the secret gate armed on every machine that deploys git -C "$REPO" config core.hooksPath .githooks echo "deploy.sh [$MODE] host=$HOST repo=$REPO — actions=$actions quarantined=$quarantined skipped=$skipped"