#!/bin/bash # ============================================================================= # kitestacks-watcher.sh # Runs as a systemd service. Watches configured directories, then on change: # 1. Pulls latest from Forgejo (avoid conflicts) # 2. Copies changed files into the correct apps/ or clusters/ folder # 3. Bumps the version in README.md and the docs/ changelog file # 4. Commits with a descriptive message # 5. Pushes to active repo (test first, then main after promote.sh) # ============================================================================= set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CONFIG_FILE="$SCRIPT_DIR/../config/settings.conf" STATE_FILE="/opt/kitestacks-autosync/.active_target" WORK_DIR="/opt/kitestacks-autosync" log() { echo "[autosync] $(date '+%Y-%m-%d %H:%M:%S') INFO $*"; } warn() { echo "[autosync] $(date '+%Y-%m-%d %H:%M:%S') WARN $*"; } err() { echo "[autosync] $(date '+%Y-%m-%d %H:%M:%S') ERROR $*" >&2; } # ── Load config ────────────────────────────────────────────────────────────── [[ ! -f "$CONFIG_FILE" ]] && { err "Config not found: $CONFIG_FILE"; exit 1; } source "$CONFIG_FILE" # ── Helpers ─────────────────────────────────────────────────────────────────── active_target() { cat "$STATE_FILE" 2>/dev/null || echo "test"; } active_repo() { [[ "$(active_target)" == "main" ]] && echo "$MAIN_REPO" || echo "$TEST_REPO" } repo_dir() { echo "$WORK_DIR/$(active_repo)"; } # ── Version management ──────────────────────────────────────────────────────── read_version() { local readme="$(repo_dir)/README.md" # Look for a line like: or **Version:** 1.3.2 local ver ver=$(grep -oP '(?<=version:\s)[\d.]+' "$readme" 2>/dev/null | head -1) [[ -z "$ver" ]] && ver=$(grep -oP '\d+\.\d+\.\d+' "$readme" 2>/dev/null | head -1) echo "${ver:-$VERSION_SEED}" } bump_version() { local ver="$1" IFS='.' read -ra p <<< "$ver" local maj="${p[0]:-1}" min="${p[1]:-3}" pat="${p[2]:-0}" case "${VERSION_BUMP:-patch}" in major) echo "$((maj+1)).0.0" ;; minor) echo "${maj}.$((min+1)).0" ;; *) echo "${maj}.${min}.$((pat+1))" ;; esac } # ── README.md update ────────────────────────────────────────────────────────── # Maintains the KiteStacks README.md format with version tracking. update_readme() { local dir="$1" new_ver="$2" old_ver="$3" local readme="$dir/README.md" local ts; ts="$(date '+%Y-%m-%d %H:%M:%S')" # If version comment tag exists, update it; otherwise append one after title if grep -q '||" "$readme" else # Inject version tag after the first H1 line sed -i "0,/^# /{s|^# \(.*\)|# \1\n\n|}" "$readme" fi log "README.md version tag: $old_ver → $new_ver" } # ── Docs changelog update ───────────────────────────────────────────────────── # Updates docs/KiteStacks-Homelab-Documentation-v.md # Creates a new versioned doc file and a CHANGELOG.md entry. update_docs() { local dir="$1" new_ver="$2" changed_files="$3" local ts; ts="$(date '+%Y-%m-%d %H:%M:%S')" local docs_dir="$dir/docs" mkdir -p "$docs_dir" # ── New versioned doc file ──────────────────────────────────────────────── local doc_file="$docs_dir/KiteStacks-Homelab-Documentation-v${new_ver}.md" cat > "$doc_file" </dev/null \ | sort -V \ | while read f; do v=$(basename "$f" .md | grep -oP '[\d.]+$') echo "- [v${v}]($(basename "$f"))" done) EOF log "Created doc file: $(basename "$doc_file")" # ── CHANGELOG.md ───────────────────────────────────────────────────────── local changelog="$dir/CHANGELOG.md" if [[ ! -f "$changelog" ]]; then echo -e "# Changelog\n\nAll notable changes to KiteStacks Homelab are documented here.\n" > "$changelog" fi # Prepend new entry after the header (after line 3) local entry entry=$(cat < "$tmp" echo "$entry" >> "$tmp" tail -n +4 "$changelog" >> "$tmp" mv "$tmp" "$changelog" log "CHANGELOG.md updated with v${new_ver}" } # ── Map a filesystem path to a repo subfolder ───────────────────────────────── # Files from ~/docker/homepage → apps/homepage/ # Files from ~/docker/ → apps// # Files from /etc/kitestacks/ → clusters/assassin/ map_to_repo_path() { local src="$1" local rel="" for watch in $WATCH_DIRS; do if [[ "$src" == "$watch"* ]]; then rel="${src#$watch/}" # Top-level dir under watch becomes the app folder local top; top=$(echo "$rel" | cut -d'/' -f1) if [[ "$watch" == *docker* ]]; then echo "apps/${rel}" else echo "clusters/assassin/${rel}" fi return fi done # Fallback echo "server-files/${src#/}" } # ── Sync changed files into the workspace ───────────────────────────────────── sync_files() { local repo="$1"; shift local files=("$@") local synced=() for src in "${files[@]}"; do local dest_rel; dest_rel="$(map_to_repo_path "$src")" local dest="$repo/$dest_rel" mkdir -p "$(dirname "$dest")" if [[ -f "$src" ]]; then cp -p "$src" "$dest" synced+=("$dest_rel") log "Synced: $dest_rel" else rm -f "$dest" synced+=("DELETED: $dest_rel") log "Removed: $dest_rel" fi done printf '%s\n' "${synced[@]}" } # ── Commit and push ─────────────────────────────────────────────────────────── commit_and_push() { local dir="$1" version="$2" file_count="$3" cd "$dir" git add -A if git diff --cached --quiet; then log "Nothing to commit." return 0 fi local target; target="$(active_target)" local msg="Automated update: $(date '+%Y-%m-%d %H:%M:%S')" # Match the commit style already in your repo: "Automated update: YYYY-MM-DD HH:MM:SS" git commit -m "$msg" log "Pushing to ${target} repo ($(active_repo))..." if git push origin HEAD 2>&1; then log "✓ Push OK — v${version}" else err "Push failed. Will retry on next change." return 1 fi } # ── Should this path be skipped? ───────────────────────────────────────────── is_excluded() { local path="$1" for pat in $EXCLUDE_PATTERNS; do case "$path" in $pat) return 0 ;; esac done return 1 } # ════════════════════════════════════════════════════════════════════════════ # MAIN # ════════════════════════════════════════════════════════════════════════════ mkdir -p "$WORK_DIR" log "KiteStacks AutoSync starting up" log "Active target : $(active_target) → $(active_repo)" log "Watching : $WATCH_DIRS" log "Debounce : ${DEBOUNCE_SECONDS}s" # Pull latest before we start watching RDIR="$(repo_dir)" if [[ -d "$RDIR/.git" ]]; then cd "$RDIR" git pull --rebase origin HEAD 2>/dev/null && log "Pulled latest from remote." || warn "Pull failed — continuing anyway." fi declare -A PENDING LAST_EVENT=0 # Build the inotifywait argument list from WATCH_DIRS IFS=' ' read -ra WATCH_ARRAY <<< "$WATCH_DIRS" inotifywait -m -r \ -e modify,create,delete,moved_to,moved_from \ --format '%w%f' \ --exclude '\.git' \ "${WATCH_ARRAY[@]}" 2>/dev/null | while IFS= read -r changed; do is_excluded "$changed" && continue PENDING["$changed"]=1 LAST_EVENT=$(date +%s) # ── Debounce loop ───────────────────────────────────────────────────────── while true; do sleep 1 ELAPSED=$(( $(date +%s) - LAST_EVENT )) [[ $ELAPSED -ge ${DEBOUNCE_SECONDS:-15} ]] && break # Drain any additional events that arrived during sleep while IFS= read -r -t 0.1 extra; do is_excluded "$extra" && continue PENDING["$extra"]=1 LAST_EVENT=$(date +%s) done done [[ ${#PENDING[@]} -eq 0 ]] && continue log "━━ Batch of ${#PENDING[@]} change(s) detected ━━" RDIR="$(repo_dir)" OLD_VER="$(read_version)" NEW_VER="$(bump_version "$OLD_VER")" # Pull before committing cd "$RDIR" git pull --rebase origin HEAD 2>/dev/null || warn "Pre-commit pull failed." # Sync files and capture the list of repo-relative paths CHANGED_LIST="$(sync_files "$RDIR" "${!PENDING[@]}")" update_readme "$RDIR" "$NEW_VER" "$OLD_VER" update_docs "$RDIR" "$NEW_VER" "$CHANGED_LIST" commit_and_push "$RDIR" "$NEW_VER" "${#PENDING[@]}" # Clear batch unset PENDING declare -A PENDING done