Files
mac-home/sync-home-content.sh
2026-09-28 19:57:51 +02:00

552 lines
17 KiB
Bash
Executable File

#!/bin/bash
################################################################################
# Synchronisierungs-Script: mac-home/home-content <-> Home-Directory
#
# Synchronisiert nur die Dateien, die im Git-Repo versioniert sind.
# Unterstützt mehrere Modi mit Konflikt-Handling.
#
# Strategie bei Changes:
# - Content-basiert via md5sum (verlässlicher als Timestamp)
# - Automatische Backups bei lokalen Änderungen
# - Mehrere Modi für verschiedene Szenarien
################################################################################
# Note: Removed 'set -euo pipefail' as it breaks while-read loops with subshells
# We handle errors explicitly instead
# Farben
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Verzeichnisse
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$SCRIPT_DIR/home-content"
HOME_DIR="$HOME"
BACKUP_DIR="$SCRIPT_DIR/.sync-backups"
LOG_FILE="$SCRIPT_DIR/.sync-home-content.log"
# Konfiguration
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
DRY_RUN=false
VERBOSE=false
INTERACTIVE=false
AUTO_BACKUP=true
################################################################################
# Hilfsfunktionen
################################################################################
log() {
echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $*"
echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE"
}
error() {
echo -e "${RED}[ERROR]${NC} $*" >&2
echo "[ERROR] $*" >> "$LOG_FILE"
}
success() {
echo -e "${GREEN}[OK]${NC} $*"
echo "[OK] $*" >> "$LOG_FILE"
}
warn() {
echo -e "${YELLOW}[WARN]${NC} $*"
echo "[WARN] $*" >> "$LOG_FILE"
}
verbose() {
if [[ "$VERBOSE" == true ]]; then
echo -e "${BLUE}[DEBUG]${NC} $*"
echo "[DEBUG] $*" >> "$LOG_FILE"
fi
}
################################################################################
# Berechne md5sum für eine Datei (mit Fallback für verschiedene OSs)
################################################################################
get_checksum() {
local file="$1"
if [[ -f "$file" ]]; then
if command -v md5 &> /dev/null; then
# macOS
md5 -q "$file" 2>/dev/null
elif command -v md5sum &> /dev/null; then
# Linux
md5sum "$file" 2>/dev/null | awk '{print $1}'
else
error "Weder md5 noch md5sum verfügbar"
return 1
fi
fi
}
################################################################################
# Erstelle Backup einer Datei
################################################################################
backup_file() {
local src="$1"
local desc="$2"
[[ ! -f "$src" ]] && return 0
mkdir -p "$BACKUP_DIR"
local backup_file="$BACKUP_DIR/$(basename "$src").$TIMESTAMP.backup"
cp "$src" "$backup_file"
log "Backup erstellt: $desc → $backup_file"
}
################################################################################
# Status einer Datei prüfen
################################################################################
# Vergleicht Datei im Repo mit Home-Dir
# Returns:
# 0 = identisch
# 1 = nur in Repo
# 2 = nur im Home
# 3 = unterschiedlicher Inhalt
# 4 = nur im Repo, nicht im Home
check_file_status() {
local repo_file="$1"
local home_file="$2"
local repo_name=$(basename "$repo_file")
if [[ ! -f "$repo_file" ]]; then
return 1 # nicht im Repo
fi
if [[ ! -f "$home_file" ]]; then
return 4 # nur im Repo
fi
local repo_sum=$(get_checksum "$repo_file")
local home_sum=$(get_checksum "$home_file")
if [[ "$repo_sum" == "$home_sum" ]]; then
return 0 # identisch
else
return 3 # unterschiedlich
fi
}
################################################################################
# Modus: STATUS
# Zeigt Unterschiede zwischen Repo und Home
################################################################################
mode_status() {
log "Status: Vergleiche Repo mit Home-Dir..."
local identical=0
local modified=0
local repo_only=0
local repo_file
local basename_file
local home_file
local status
while IFS= read -r file; do
repo_file="$REPO_DIR/$file"
basename_file=$(basename "$file")
home_file="$HOME_DIR/$basename_file"
check_file_status "$repo_file" "$home_file"
status=$?
case $status in
0)
verbose "✓ identisch: $basename_file"
((identical++))
;;
3)
warn "≠ geändert im Home: $basename_file"
if [[ "$VERBOSE" == true ]]; then
echo " Repo: $(get_checksum "$repo_file")"
echo " Home: $(get_checksum "$home_file")"
fi
((modified++))
;;
4)
warn "✗ nur im Repo: $basename_file (nicht im Home)"
((repo_only++))
;;
esac
done < <(cd "$REPO_DIR" && git ls-files)
echo ""
success "Status-Zusammenfassung:"
echo " Identisch: $identical"
echo " Geändert: $modified"
echo " Nur im Repo: $repo_only"
}
################################################################################
# Modus: PULL
# Home-Dir → Repo (kopiere geänderte Dateien von Home ins Repo)
################################################################################
mode_pull() {
log "Pull: Synchronisiere geänderte Dateien von Home-Dir ins Repo..."
local updated=0
local skipped=0
while IFS= read -r file; do
local repo_file="$REPO_DIR/$file"
local basename=$(basename "$file")
local home_file="$HOME_DIR/$basename"
check_file_status "$repo_file" "$home_file"
local status=$?
case $status in
0)
verbose "⊙ unverändert: $basename"
;;
3)
warn "← update: $basename (Home → Repo)"
if [[ "$DRY_RUN" == false ]]; then
[[ "$AUTO_BACKUP" == true ]] && backup_file "$repo_file" "$basename (vor Pull)"
cp "$home_file" "$repo_file"
((updated++))
fi
;;
4)
verbose "⊘ ausgelassen: $basename (nicht im Home)"
((skipped++))
;;
esac
done < <(cd "$REPO_DIR" && git ls-files)
success "Pull abgeschlossen: $updated aktualisiert, $skipped ausgelassen"
}
################################################################################
# Modus: PUSH
# Repo → Home (kopiere Repo-Dateien ins Home-Dir)
################################################################################
mode_push() {
log "Push: Synchronisiere Repo-Dateien ins Home-Dir..."
local updated=0
local backed_up=0
local skipped=0
while IFS= read -r file; do
local repo_file="$REPO_DIR/$file"
local basename=$(basename "$file")
local home_file="$HOME_DIR/$basename"
check_file_status "$repo_file" "$home_file"
local status=$?
case $status in
0)
verbose "⊙ unverändert: $basename"
;;
3)
warn "→ update: $basename (Repo → Home) [lokale Änderung wird überschrieben]"
if [[ "$DRY_RUN" == false ]]; then
if [[ "$AUTO_BACKUP" == true ]]; then
backup_file "$home_file" "$basename (vor Push)"
((backed_up++))
fi
cp "$repo_file" "$home_file"
((updated++))
fi
;;
4)
verbose "✚ neu: $basename (Repo → Home)"
if [[ "$DRY_RUN" == false ]]; then
cp "$repo_file" "$home_file"
((updated++))
fi
;;
esac
done < <(cd "$REPO_DIR" && git ls-files)
success "Push abgeschlossen: $updated kopiert, $backed_up Backups, $skipped ausgelassen"
}
################################################################################
# Modus: SYNC (bidirektional mit Konflikt-Handling)
################################################################################
mode_sync() {
log "Sync: Bidirektionale Synchronisierung..."
local updated=0
local backed_up=0
local conflicts=0
while IFS= read -r file; do
local repo_file="$REPO_DIR/$file"
local basename=$(basename "$file")
local home_file="$HOME_DIR/$basename"
check_file_status "$repo_file" "$home_file"
local status=$?
case $status in
0)
verbose "⊙ unverändert: $basename"
;;
3)
# Konflikt: beide unterschiedlich
if [[ "$INTERACTIVE" == true ]]; then
echo ""
warn "KONFLIKT: $basename unterscheidet sich"
echo " Repo Checksum: $(get_checksum "$repo_file")"
echo " Home Checksum: $(get_checksum "$home_file")"
echo ""
echo "Optionen:"
echo " 1) Nehme Repo-Version (→ Home überschreiben)"
echo " 2) Nehme Home-Version (← Repo überschreiben)"
echo " 3) Behalte beide (Home-Version wird gebackupt)"
echo " 4) Überspringe diese Datei"
read -p "Wahl [1-4]: " choice
case $choice in
1)
[[ "$DRY_RUN" == false ]] && {
backup_file "$home_file" "$basename (Konflikt-Lösung)"
cp "$repo_file" "$home_file"
((updated++))
}
;;
2)
[[ "$DRY_RUN" == false ]] && {
backup_file "$repo_file" "$basename (Konflikt-Lösung)"
cp "$home_file" "$repo_file"
((updated++))
}
;;
3)
[[ "$DRY_RUN" == false ]] && backup_file "$home_file" "$basename (Konflikt-Aufbewahrung)"
((backed_up++))
;;
4)
((conflicts++))
;;
esac
else
# Non-interactive: Home gewinnt (lokal neuere Version wird bevorzugt)
local repo_mtime=$(stat -f%m "$repo_file" 2>/dev/null || stat -c%Y "$repo_file")
local home_mtime=$(stat -f%m "$home_file" 2>/dev/null || stat -c%Y "$home_file")
if (( home_mtime > repo_mtime )); then
verbose "← konflikt (Home neuerer): $basename → Repo wird aktualisiert"
if [[ "$DRY_RUN" == false ]]; then
backup_file "$repo_file" "$basename (Konflikt)"
cp "$home_file" "$repo_file"
((updated++))
fi
else
verbose "→ konflikt (Repo neuerer): $basename → Home wird aktualisiert"
if [[ "$DRY_RUN" == false ]]; then
backup_file "$home_file" "$basename (Konflikt)"
cp "$repo_file" "$home_file"
((updated++))
fi
fi
fi
;;
4)
verbose "✚ neu: $basename (Repo → Home)"
if [[ "$DRY_RUN" == false ]]; then
cp "$repo_file" "$home_file"
((updated++))
fi
;;
esac
done < <(cd "$REPO_DIR" && git ls-files)
success "Sync abgeschlossen: $updated synchronisiert, $backed_up Backups, $conflicts Konflikte"
}
################################################################################
# Modus: CLEANUP
# Entferne alte Backup-Dateien (älter als 30 Tage)
################################################################################
mode_cleanup() {
log "Cleanup: Entferne alte Backups (älter als 30 Tage)..."
if [[ ! -d "$BACKUP_DIR" ]]; then
success "Kein Backup-Verzeichnis vorhanden"
return 0
fi
local removed=0
while IFS= read -r backup_file; do
rm -f "$backup_file"
verbose "Gelöscht: $backup_file"
((removed++))
done < <(find "$BACKUP_DIR" -type f -mtime +30)
success "Cleanup abgeschlossen: $removed alte Backups gelöscht"
}
################################################################################
# Modus: LIST
# Liste alle verwalteten Dateien auf
################################################################################
mode_list() {
log "Liste alle verwalteten Dateien:"
echo ""
cd "$REPO_DIR" && git ls-files | while read -r file; do
echo " • $file"
done
}
################################################################################
# Hilfefunktion
################################################################################
show_help() {
cat << EOF
Verwendung: $0 [MODE] [OPTIONS]
MODI:
status Zeige Unterschiede zwischen Repo und Home-Dir (Standard)
pull Synchronisiere von Home-Dir ins Repo (Home → Repo)
push Synchronisiere vom Repo ins Home-Dir (Repo → Home)
sync Bidirektionale Synchronisierung mit Konflikt-Handling
cleanup Entferne alte Backups (älter als 30 Tage)
list Liste alle verwalteten Dateien auf
OPTIONEN:
-d, --dry-run Zeige nur Änderungen, ohne zu modifizieren
-v, --verbose Verbose Output (DEBUG)
-i, --interactive Interaktive Konflikte lösen (nur für sync)
--no-backup Keine automatischen Backups erstellen
-h, --help Diese Hilfe anzeigen
BEISPIELE:
# Status anzeigen
$0 status
# Von Home ins Repo synchronisieren (Trockentest)
$0 pull --dry-run
# Bidirektional mit Backup synchronisieren
$0 sync
# Interaktiv mit Konflikt-Handling
$0 sync --interactive
STRATEGIE:
• Content-basiert via md5sum (zuverlässiger als Timestamps)
• Automatische Backups in .sync-backups/ vor Änderungen
• Nur Dateien synchronisieren, die in git versioniert sind
• Logs in .sync-home-content.log
EOF
}
################################################################################
# Hauptprogramm
################################################################################
main() {
local mode="status"
# Parse Argumente
while [[ $# -gt 0 ]]; do
case $1 in
status|pull|push|sync|cleanup|list)
mode="$1"
shift
;;
-d|--dry-run)
DRY_RUN=true
shift
;;
-v|--verbose)
VERBOSE=true
shift
;;
-i|--interactive)
INTERACTIVE=true
shift
;;
--no-backup)
AUTO_BACKUP=false
shift
;;
-h|--help)
show_help
exit 0
;;
*)
error "Unbekannte Option: $1"
show_help
exit 1
;;
esac
done
# Validierungen
if [[ ! -d "$REPO_DIR" ]]; then
error "Repo-Verzeichnis nicht gefunden: $REPO_DIR"
exit 1
fi
if ! git -C "$REPO_DIR" rev-parse --git-dir > /dev/null 2>&1; then
error "$REPO_DIR ist kein Git-Repository"
exit 1
fi
# Header
log "═══════════════════════════════════════════════════════════"
log "Modus: $mode"
[[ "$DRY_RUN" == true ]] && log "DRY-RUN: Keine Änderungen werden vorgenommen"
[[ "$INTERACTIVE" == true ]] && log "INTERAKTIV: Bei Konflikten wird nachgefragt"
log "═══════════════════════════════════════════════════════════"
# Führe Modus aus
case $mode in
status)
mode_status
;;
pull)
mode_pull
;;
push)
mode_push
;;
sync)
mode_sync
;;
cleanup)
mode_cleanup
;;
list)
mode_list
;;
*)
error "Unbekannter Modus: $mode"
show_help
exit 1
;;
esac
log "═══════════════════════════════════════════════════════════"
[[ "$DRY_RUN" == true ]] && success "DRY-RUN abgeschlossen (keine Änderungen)" || success "Abgeschlossen"
}
# Starte Script
main "$@"