#!/usr/bin/env bash # pkgsync: sincroniza qué paquetes (pacman + AUR + flatpak) hay instalados # entre las dos máquinas, usando shared/packages-common.sh (en un clon propio # del repo arch-setup) como fuente de verdad y metapac como motor de # diff/aplicación. Mismo patrón que chezmoi-boot-sync.sh/chezmoi-shutdown-sync.sh # (repo arch-configs) pero para paquetes en vez de dotfiles. # # pkgsync.sh bootstrap instala metapac + config, primera vez o retrofit # pkgsync.sh push captura inventario real -> arrays -> commit+push (pkgsync-push.service) # pkgsync.sh pull git pull + diff + notifica, NO instala/desinstala (pkgsync-pull.service) # pkgsync.sh diff como pull pero solo imprime, sin tocar git ni notificar # pkgsync.sh apply metapac sync + metapac clean, ambos con confirmación interactiva # pkgsync.sh log [--patch] # pkgsync.sh incoming # pkgsync.sh audit set -uo pipefail REPO_URL="https://gitea.josevi.ovh/josevi/arch-setup.git" REPO_DIR="$HOME/.local/share/arch-setup" LOG="$HOME/.local/share/pkgsync.log" METAPAC_CONFIG_DIR="$HOME/.config/metapac" METAPAC_GROUP="$METAPAC_CONFIG_DIR/groups/arch-setup.toml" OVERRIDES_FILE="$METAPAC_CONFIG_DIR/groups/overrides.local.toml" # Marca que un push falló. pull la revisa al arrancar (con la sesión gráfica # ya arriba) para avisar aunque el notify-send del propio push, disparado # durante el apagado con la sesión cayéndose, no llegara a mostrarse. Se # borra en el primer push que vuelve a tener éxito. FAIL_MARKER="$HOME/.local/share/pkgsync.failed" # Token de Gotify (push.josevi.ovh, mismo servidor que usa WUD) para avisos # de fallo -- llega también sin sesión gráfica, a diferencia de notify-send. # Fichero local-only, nunca tracked (ni chezmoi ni el repo arch-setup): # mismo criterio que OVERRIDES_FILE. Si no existe, se omite sin más. GOTIFY_ENV="$HOME/.config/pkgsync/gotify.env" # flatpak sin --user/--system instala en "system" con la config actual de # install_flatpaks() en packages-common.sh. Si tu máquina usa --user, cambia # esto (o verifica con: flatpak list --columns=installation,application). FLATPAK_INSTALLATION="system" # Paquetes que NUNCA se borran automáticamente, pase lo que pase en el array # en un momento dado -- red de seguridad independiente, para que sobreviva # aunque el array se corrompa por error humano. Confirmada con el usuario # 2026-08-18 (kernel/arranque, gráficos, red de rollback btrfs, y todo lo que # sea soporte de hardware: CPU/energía, audio, bluetooth, red física, USB). CRITICAL_PACKAGES=( linux linux-lts linux-lts-headers linux-firmware grub efibootmgr amd-ucode mesa vulkan-icd-loader vulkan-radeon vulkan-intel intel-media-driver btrfs-progs snapper snap-pac grub-btrfs inotify-tools zram-generator cpupower power-profiles-daemon pipewire pipewire-alsa pipewire-jack pipewire-pulse wireplumber libpulse gst-plugin-pipewire gst-plugins-good gst-plugins-bad gst-libav sof-firmware bluez bluez-utils networkmanager wpa_supplicant smartmontools usbutils gvfs-mtp ) # --- inventario real --------------------------------------------------------- installed_pacman_native() { pacman -Qqen 2>/dev/null; } # explícitos de repos oficiales installed_pacman_foreign() { pacman -Qqem 2>/dev/null; } # explícitos AUR/locales installed_flatpak() { command -v flatpak &>/dev/null || return 0 flatpak list --app --columns=application 2>/dev/null } # --- arrays declarados (leídos del clon en $REPO_DIR) ----------------------- # Sourcea packages-common.sh en un subshell: solo lee los arrays, nunca llama # a los install_*. declared_array() { local name="$1" ( source "$REPO_DIR/shared/packages-common.sh" 2>/dev/null case "$name" in PACMAN_COMMON) printf '%s\n' "${PACMAN_COMMON[@]}" ;; AUR_COMMON) printf '%s\n' "${AUR_COMMON[@]}" ;; FLATPAK_COMMON) printf '%s\n' "${FLATPAK_COMMON[@]}" ;; esac ) } # Paquetes en el override local (parser mínimo: basta para sacar los # packages = [...] de [arch] y [flatpak], ignorando líneas comentadas). overrides_packages() { [[ -f "$OVERRIDES_FILE" ]] || return 0 awk ' /^[[:space:]]*#/ { next } /packages[[:space:]]*=[[:space:]]*\[/ { inarr=1 } inarr { while (match($0, /"[^"]+"/)) { print substr($0, RSTART+1, RLENGTH-2) $0 = substr($0, RSTART+RLENGTH) } } /\]/ { inarr=0 } ' "$OVERRIDES_FILE" } # --- parcheo del array en packages-common.sh sin tocar comentarios ---------- # Quita de $array_name los tokens listados en $remove_list (uno por línea) y # añade los de $add_list en un bloque marcador al final, dejando el resto de # líneas (incluidos los comentarios de agrupación) intactas. patch_array_file() { local file="$1" array_name="$2" remove_list="$3" add_list="$4" local marker=" # --- pkgsync: nuevos detectados (revisa y reorganiza a mano) ---" local -A to_remove=() local pkg while IFS= read -r pkg; do [[ -n "$pkg" ]] && to_remove["$pkg"]=1 done <<<"$remove_list" local -a lines mapfile -t lines <"$file" local start_idx=-1 end_idx=-1 i for i in "${!lines[@]}"; do if [[ "${lines[$i]}" == "${array_name}=(" ]]; then start_idx=$i; break; fi done if (( start_idx < 0 )); then echo "!! no encuentro el array $array_name en $file" >&2 return 1 fi for ((i = start_idx + 1; i < ${#lines[@]}; i++)); do if [[ "${lines[$i]}" == ")" ]]; then end_idx=$i; break; fi done if (( end_idx < 0 )); then echo "!! no encuentro el cierre de $array_name en $file" >&2 return 1 fi # Ojo: las líneas de paquetes que quedaron bajo el marcador de una pasada # anterior NO se descartan aquí -- pasan por el mismo filtro de to_remove # que cualquier otra línea, así que lo que ya se añadió sigue en el array # aunque esta pasada no tenga nada nuevo que añadir (add_list vacío). Solo # se quita la línea de comentario del marcador en sí, para no acumular # comentarios duplicados; si hay contenido nuevo se vuelve a escribir abajo. local -a new_body=() for ((i = start_idx + 1; i < end_idx; i++)); do local line="${lines[$i]}" if [[ "$line" == "$marker" ]]; then continue; fi if [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]]; then new_body+=("$line") continue fi local indent="${line%%[^[:space:]]*}" local -a tokens=($line) local -a kept=() local t for t in "${tokens[@]}"; do [[ -n "${to_remove[$t]:-}" ]] || kept+=("$t") done (( ${#kept[@]} > 0 )) && new_body+=("${indent}${kept[*]}") done if [[ -n "$add_list" ]]; then new_body+=("$marker") local p while IFS= read -r p; do [[ -n "$p" ]] && new_body+=(" $p") done <<<"$add_list" fi local tmp tmp="$(mktemp)" printf '%s\n' "${lines[@]:0:start_idx+1}" "${new_body[@]}" "${lines[@]:end_idx}" >"$tmp" mv "$tmp" "$file" } # --- group file de metapac --------------------------------------------------- # Regenerado en bootstrap/push/pull. Fuerza la unión con CRITICAL_PACKAGES: es # la segunda capa de protección, para que metapac nunca vea esos paquetes como # "no declarados" aunque el array se corrompiera. write_group_file() { mkdir -p "$(dirname "$METAPAC_GROUP")" { echo "# generado por pkgsync -- no editar a mano, se sobreescribe en cada bootstrap/push/pull" echo "[arch]" echo "packages = [" { declared_array PACMAN_COMMON; declared_array AUR_COMMON; printf '%s\n' "${CRITICAL_PACKAGES[@]}"; } \ | sort -u | while IFS= read -r p; do [[ -n "$p" ]] && printf ' "%s",\n' "$p"; done echo "]" echo echo "[flatpak]" echo "packages = [" declared_array FLATPAK_COMMON | sort -u | while IFS= read -r p; do [[ -n "$p" ]] && printf ' { name = "%s:%s", options = { remote = "flathub" } },\n' "$FLATPAK_INSTALLATION" "$p" done echo "]" } >"$METAPAC_GROUP" } # --- diff declarado vs instalado -------------------------------------------- # Deja el resultado en arrays globales: TO_INSTALL_* (declarado, no instalado) # y TO_REVIEW_REMOVE_* (instalado, no declarado). compute_diff() { local work work="$(mktemp -d)" { declared_array PACMAN_COMMON; declared_array AUR_COMMON; printf '%s\n' "${CRITICAL_PACKAGES[@]}"; } | sort -u >"$work/declared_arch" declared_array FLATPAK_COMMON | sort -u >"$work/declared_flatpak" overrides_packages | sort -u >"$work/overrides" { installed_pacman_native; installed_pacman_foreign; } | sort -u >"$work/installed_arch" installed_flatpak | sort -u >"$work/installed_flatpak" mapfile -t TO_INSTALL_ARCH < <(comm -23 "$work/declared_arch" "$work/installed_arch" | comm -23 - "$work/overrides") mapfile -t TO_REVIEW_REMOVE_ARCH < <(comm -13 "$work/declared_arch" "$work/installed_arch" | comm -23 - "$work/overrides") mapfile -t TO_INSTALL_FLATPAK < <(comm -23 "$work/declared_flatpak" "$work/installed_flatpak" | comm -23 - "$work/overrides") mapfile -t TO_REVIEW_REMOVE_FLATPAK < <(comm -13 "$work/declared_flatpak" "$work/installed_flatpak" | comm -23 - "$work/overrides") rm -rf "$work" } print_list() { if (( $# == 0 )); then echo " (nada)"; return; fi printf ' %s\n' "$@" } print_diff() { echo "== pendiente de instalar (declarado, no instalado) ==" print_list "${TO_INSTALL_ARCH[@]}" "${TO_INSTALL_FLATPAK[@]}" echo echo "== instalado pero no declarado (candidato a revisar/borrar con 'pkgsync apply') ==" print_list "${TO_REVIEW_REMOVE_ARCH[@]}" "${TO_REVIEW_REMOVE_FLATPAK[@]}" } notify() { # timeout: notify-send puede colgarse activando por D-Bus el servicio de # notificaciones si algo va raro en la sesión -- que eso nunca bloquee el # pull entero (nos pasó una vez: se comió los 60s de TimeoutStartSec del # servicio systemd y lo mató). command -v notify-send &>/dev/null && timeout 5 notify-send --app-name=pkgsync "$1" "$2" } # Como notify(), pero urgencia "critical" (se queda en pantalla hasta que se # cierra a mano) y además deja constancia en $FAIL_MARKER: push corre en # ExecStop durante el apagado, con la sesión/D-Bus ya cayéndose, así que su # notify-send puede no llegar a mostrarse -- el marcador es la red de # seguridad que garantiza el aviso en el siguiente pull, ya con la sesión # gráfica completa. notify_fail() { echo "!! $1: $2" >&2 printf '%s -- %s: %s\n' "$(date -Iseconds)" "$1" "$2" >"$FAIL_MARKER" command -v notify-send &>/dev/null && timeout 5 notify-send --app-name=pkgsync --urgency=critical "$1" "$2" notify_gotify "$1" "$2" } # Best-effort, nunca bloquea ni hace fallar el caller: mismo espíritu que el # timeout de notify-send de arriba, pero via HTTPS -- llega aunque D-Bus/la # sesión gráfica ya esté cayéndose (caso ExecStop en apagado) o no haya # sesión gráfica en absoluto. notify_gotify() { [[ -f "$GOTIFY_ENV" ]] || return 0 local GOTIFY_URL GOTIFY_TOKEN # shellcheck disable=SC1090 source "$GOTIFY_ENV" [[ -n "${GOTIFY_URL:-}" && -n "${GOTIFY_TOKEN:-}" ]] || return 0 timeout 10 curl -fsS --max-time 8 \ -F "title=$1" -F "message=$2" -F "priority=8" \ "${GOTIFY_URL%/}/message?token=${GOTIFY_TOKEN}" >/dev/null 2>&1 } # --- subcomandos -------------------------------------------------------------- cmd_bootstrap() { echo ">> pkgsync bootstrap" if ! command -v metapac &>/dev/null; then echo ">> instalando metapac (AUR)..." if command -v paru &>/dev/null; then paru -S --needed --noconfirm metapac else echo "!! no encuentro paru; instala metapac a mano (helper AUR) y vuelve a correr 'pkgsync bootstrap'." >&2 return 1 fi fi mkdir -p "$METAPAC_CONFIG_DIR/groups" "$(dirname "$REPO_DIR")" "$(dirname "$LOG")" if [[ ! -f "$METAPAC_CONFIG_DIR/config.toml" ]]; then cat >"$METAPAC_CONFIG_DIR/config.toml" <<'EOF' enabled_backends = ["arch", "flatpak"] [arch] package_manager = "paru" EOF echo ">> $METAPAC_CONFIG_DIR/config.toml creado" fi if [[ ! -f "$OVERRIDES_FILE" ]]; then cat >"$OVERRIDES_FILE" <<'EOF' # Paquetes que SOLO tienen sentido en ESTA máquina. # # pkgsync nunca lee ni escribe este fichero de forma automática: no vive en # el repo arch-setup, no viaja a la otra máquina. metapac sí lo combina con # el resto de group files de ~/.config/metapac/groups/ para sus propios # sync/clean, así que un paquete puesto aquí queda protegido de 'pkgsync # apply' igual que si estuviera en el array compartido. # # Descomenta y edita, por ejemplo: # # [arch] # packages = ["algun-paquete-solo-de-esta-maquina"] # # [flatpak] # packages = ["system:com.algo.SoloAqui"] EOF echo ">> scaffold de $OVERRIDES_FILE creado (vacío, edítalo a mano)" fi if [[ ! -f "$GOTIFY_ENV" ]]; then mkdir -p "$(dirname "$GOTIFY_ENV")" cat >"$GOTIFY_ENV" <<'EOF' # Token de aplicación Gotify para los avisos de fallo de pkgsync (push.josevi.ovh). # Crea una Application nueva en la UI de Gotify y pega su token aquí. # Fichero local-only: pkgsync nunca lo lee/escribe salvo aquí, no viaja al repo. #GOTIFY_URL="https://push.josevi.ovh" #GOTIFY_TOKEN="pon-aqui-el-token" EOF chmod 600 "$GOTIFY_ENV" echo ">> scaffold de $GOTIFY_ENV creado (comentado, edítalo a mano para activar avisos por Gotify)" fi if [[ ! -d "$REPO_DIR/.git" ]]; then echo ">> clonando arch-setup en $REPO_DIR..." git clone "$REPO_URL" "$REPO_DIR" fi write_group_file echo ">> bootstrap completo. Activa los servicios con:" echo " systemctl --user enable --now pkgsync-push.service pkgsync-pull.service" } cmd_push() { mkdir -p "$(dirname "$LOG")" exec >>"$LOG" 2>&1 echo "=== push $(date -Iseconds) ===" if [[ ! -d "$REPO_DIR/.git" ]]; then notify_fail "pkgsync: push falló" "no existe el clon $REPO_DIR, corre 'pkgsync bootstrap'" return 0 fi local work work="$(mktemp -d)" installed_pacman_native | sort -u >"$work/inst_native" installed_pacman_foreign | sort -u >"$work/inst_foreign" cat "$work/inst_native" "$work/inst_foreign" | sort -u >"$work/inst_arch_any" installed_flatpak | sort -u >"$work/inst_flatpak" overrides_packages | sort -u >"$work/overrides" printf '%s\n' "${CRITICAL_PACKAGES[@]}" | sort -u >"$work/critical" declared_array PACMAN_COMMON | sort -u >"$work/decl_pacman" declared_array AUR_COMMON | sort -u >"$work/decl_aur" declared_array FLATPAK_COMMON | sort -u >"$work/decl_flatpak" cat "$work/decl_pacman" "$work/decl_aur" | sort -u >"$work/decl_arch_any" comm -23 "$work/critical" "$work/inst_arch_any" | while IFS= read -r c; do [[ -n "$c" ]] && echo "!! AVISO: paquete crítico '$c' no está instalado localmente; se mantiene declarado (no se toca)" done local remove_pacman add_pacman remove_aur add_aur remove_flatpak add_flatpak remove_pacman="$(comm -23 "$work/decl_pacman" "$work/inst_arch_any" | comm -23 - "$work/overrides" | comm -23 - "$work/critical")" add_pacman="$(comm -23 "$work/inst_native" "$work/decl_arch_any" | comm -23 - "$work/overrides")" remove_aur="$(comm -23 "$work/decl_aur" "$work/inst_arch_any" | comm -23 - "$work/overrides" | comm -23 - "$work/critical")" add_aur="$(comm -23 "$work/inst_foreign" "$work/decl_arch_any" | comm -23 - "$work/overrides")" remove_flatpak="$(comm -23 "$work/decl_flatpak" "$work/inst_flatpak" | comm -23 - "$work/overrides" | comm -23 - "$work/critical")" add_flatpak="$(comm -23 "$work/inst_flatpak" "$work/decl_flatpak" | comm -23 - "$work/overrides")" local pc="$REPO_DIR/shared/packages-common.sh" local patch_failed=0 patch_array_file "$pc" PACMAN_COMMON "$remove_pacman" "$add_pacman" || patch_failed=1 patch_array_file "$pc" AUR_COMMON "$remove_aur" "$add_aur" || patch_failed=1 patch_array_file "$pc" FLATPAK_COMMON "$remove_flatpak" "$add_flatpak" || patch_failed=1 rm -rf "$work" # No commitear nunca un parcheo a medias/fallido -- así fue como el fichero # acabó vacío en origin/main una vez (2026-08-27): el parcheo falló pero el # script siguió y commiteó igualmente lo que hubiera en disco. El fichero # de trabajo no se toca cuando patch_array_file falla (solo escribe si # encuentra el array), así que abortar aquí es seguro: el próximo intento # vuelve a partir del mismo estado. if (( patch_failed )); then notify_fail "pkgsync: push falló" "no se pudo parchear packages-common.sh (arrays no encontrados), reviso sin commitear -- $LOG" return 1 fi write_group_file cd "$REPO_DIR" || return 0 git add shared/packages-common.sh if git diff --cached --quiet; then echo "sin cambios de paquetes que subir" rm -f "$FAIL_MARKER" return 0 fi git commit -m "pkgsync: actualiza inventario de paquetes $(date '+%Y-%m-%d %H:%M')" if timeout 20 git push origin main; then echo "push OK" rm -f "$FAIL_MARKER" else notify_fail "pkgsync: push falló" "commit local OK pero 'git push' falló (¿sin red?); el commit queda en local para el próximo intento" fi } cmd_pull() { mkdir -p "$(dirname "$LOG")" exec >>"$LOG" 2>&1 echo "=== pull $(date -Iseconds) ===" # Red de seguridad: si un push anterior falló (típicamente en ExecStop # durante el apagado, con la sesión/D-Bus ya cayéndose y su propio # notify-send sin garantía de mostrarse), avisar aquí sí o sí -- pull corre # al arrancar sesión, con el escritorio ya completo. if [[ -f "$FAIL_MARKER" ]]; then notify_fail "pkgsync: hay un fallo pendiente de revisar" "$(cat "$FAIL_MARKER")" fi if ! timeout 15 git ls-remote --exit-code "$REPO_URL" &>/dev/null; then echo "sin conexión con el remoto, me quedo con el estado local" elif [[ ! -d "$REPO_DIR/.git" ]]; then echo "clonando arch-setup en $REPO_DIR..." mkdir -p "$(dirname "$REPO_DIR")" if timeout 30 git clone "$REPO_URL" "$REPO_DIR"; then echo "clon inicial OK" else notify_fail "pkgsync: pull falló" "no se pudo clonar $REPO_URL, revisa $LOG" fi else if timeout 30 git -C "$REPO_DIR" pull --ff-only origin main; then echo "git pull OK" else # ls-remote de arriba ya confirmó que hay red -- esto es un conflicto # real (fast-forward imposible), no un simple "sin conexión". notify_fail "pkgsync: pull falló" "'git pull --ff-only' falló (conflicto local, revisa $REPO_DIR a mano); sigo con el estado que haya" fi fi if [[ ! -d "$REPO_DIR/.git" ]]; then echo "no hay clon de arch-setup, no puedo calcular diff" return 0 fi write_group_file compute_diff local n_install=$(( ${#TO_INSTALL_ARCH[@]} + ${#TO_INSTALL_FLATPAK[@]} )) local n_remove=$(( ${#TO_REVIEW_REMOVE_ARCH[@]} + ${#TO_REVIEW_REMOVE_FLATPAK[@]} )) print_diff if (( n_install == 0 && n_remove == 0 )); then echo "sin cambios pendientes" else notify "pkgsync: cambios de paquetes pendientes" \ "$n_install a instalar, $n_remove a revisar para borrar. Detalle: pkgsync diff (o $LOG)" fi if [[ "${PKGSYNC_AUTO_INSTALL:-0}" == "1" ]] && (( n_install > 0 )); then echo "PKGSYNC_AUTO_INSTALL=1: instalando automáticamente lo que falta (nunca borra)" metapac sync --no-confirm fi } cmd_apply() { echo ">> pkgsync apply: metapac sync (instalar) + metapac clean (borrar, con confirmación)" [[ -d "$REPO_DIR/.git" ]] || { echo "!! no existe $REPO_DIR, corre 'pkgsync bootstrap' primero"; return 1; } write_group_file metapac sync metapac clean } cmd_log() { [[ -d "$REPO_DIR/.git" ]] || { echo "!! no hay clon en $REPO_DIR, corre 'pkgsync bootstrap' primero"; return 1; } if [[ "${1:-}" == "--patch" ]]; then git -C "$REPO_DIR" log --follow -p -- shared/packages-common.sh else git -C "$REPO_DIR" log --follow --stat -- shared/packages-common.sh fi } cmd_incoming() { [[ -d "$REPO_DIR/.git" ]] || { echo "!! no hay clon en $REPO_DIR, corre 'pkgsync bootstrap' primero"; return 1; } git -C "$REPO_DIR" fetch --quiet origin local n n="$(git -C "$REPO_DIR" log HEAD..origin/main --oneline | wc -l)" if (( n == 0 )); then echo "sin commits nuevos en origin/main" else echo "$n commit(s) nuevos en origin/main:" git -C "$REPO_DIR" log HEAD..origin/main --oneline fi } cmd_audit() { local work work="$(mktemp -d)" { declared_array PACMAN_COMMON; declared_array AUR_COMMON; printf '%s\n' "${CRITICAL_PACKAGES[@]}"; overrides_packages; } | sort -u >"$work/declared_arch" { declared_array FLATPAK_COMMON; overrides_packages; } | sort -u >"$work/declared_flatpak" installed_pacman_native | sort -u >"$work/inst_native" installed_pacman_foreign | sort -u >"$work/inst_foreign" installed_flatpak | sort -u >"$work/inst_flatpak" echo "== pacman (oficiales) instalados, no declarados en ningún array ==" comm -23 "$work/inst_native" "$work/declared_arch" echo echo "== AUR/foráneos instalados, no declarados en ningún array ==" comm -23 "$work/inst_foreign" "$work/declared_arch" echo echo "== flatpaks instalados, no declarados ==" comm -23 "$work/inst_flatpak" "$work/declared_flatpak" echo echo "(nota: los paquetes específicos de KDE hardcodeados en kde/setup.sh" echo " -- plasma-desktop, sddm, temas AUR, etc. -- siempre van a salir aquí: no" echo " viven en los arrays de packages-common.sh por diseño, no es drift real)" rm -rf "$work" } usage() { cat >&2 <<'EOF' uso: pkgsync.sh bootstrap instala metapac + config + clon local (primera vez / retrofit) push captura el inventario real y lo sube al repo (usa pkgsync-push.service) pull git pull + calcula diff + notifica; NO instala ni desinstala nada (usa pkgsync-pull.service) diff como pull pero solo imprime el diff, sin tocar git ni notificar apply aplica lo pendiente con metapac (sync + clean), pidiendo confirmación log [--patch] historial de shared/packages-common.sh incoming commits nuevos en origin/main aún no traídos audit paquetes instalados localmente que no están declarados en ningún sitio EOF } main() { local cmd="${1:-}" [[ $# -gt 0 ]] && shift case "$cmd" in bootstrap) cmd_bootstrap ;; push) cmd_push ;; pull) cmd_pull ;; diff) compute_diff; print_diff ;; apply) cmd_apply ;; log) cmd_log "$@" ;; incoming) cmd_incoming ;; audit) cmd_audit ;; *) usage; return 1 ;; esac } main "$@"