diff --git a/scripts/README-fix-submodule-branches.md b/scripts/README-fix-submodule-branches.md new file mode 100644 index 000000000..915283ed8 --- /dev/null +++ b/scripts/README-fix-submodule-branches.md @@ -0,0 +1,63 @@ +# fix-submodule-branches-prod.sh + +Bash-Script zum rekursiven Prüfen von Git-Submodulen und Umschalten von `detached HEAD` auf den passenden Branch. + +## Verhalten + +Für jedes Submodul gilt: + +1. Wenn in `.gitmodules` ein `branch` konfiguriert ist, wird **dieser** verwendet. +2. Wenn **kein** `branch` konfiguriert ist, wird der **Default-Branch von `origin`** verwendet. +3. Standardmäßig wird **nur bei `detached HEAD`** umgeschaltet. +4. Optional kann mit `--force-switch` auch von einem anderen Branch auf den Ziel-Branch gewechselt werden. +5. Submodule mit **uncommitted changes** werden aus Sicherheitsgründen **übersprungen**. +6. Verschachtelte Submodule werden rekursiv verarbeitet. + +## Voraussetzungen + +- Bash +- Git + +## Nutzung + +```bash +chmod +x fix-submodule-branches-prod.sh +./fix-submodule-branches-prod.sh [optionen] [root-dir] +``` + +## Optionen + +- `-n`, `--dry-run` + Zeigt nur an, was ausgeführt würde. + +- `--force-switch` + Wechselt auch dann auf den Ziel-Branch, wenn das Submodul bereits auf einem anderen Branch steht. + +- `--init-missing` + Initialisiert fehlende/nicht initialisierte Submodule automatisch. + +- `-h`, `--help` + Zeigt die Hilfe an. + +## Beispiele + +```bash +./fix-submodule-branches-prod.sh --dry-run /pfad/zum/repo-root +./fix-submodule-branches-prod.sh --init-missing /pfad/zum/repo-root +./fix-submodule-branches-prod.sh --init-missing --force-switch /pfad/zum/repo-root +``` + +## Ausgabe + +Am Ende wird eine Zusammenfassung ausgegeben, z. B.: + +- Anzahl geprüfter Repositories mit `.gitmodules` +- Anzahl gefundener Submodule +- Anzahl gefixter `detached HEAD`/Branch-Wechsel +- Anzahl bereits korrekter Branches +- Anzahl übersprungener dirty Submodule +- Anzahl übersprungener Fälle/Fehler + +## Sicherheitsverhalten + +Das Script überspringt Submodule mit lokalen Änderungen (`git status --porcelain` nicht leer), damit keine uncommitted Änderungen durch Branch-Wechsel gefährdet werden. \ No newline at end of file diff --git a/scripts/fix-submodule-branches.sh b/scripts/fix-submodule-branches.sh new file mode 100755 index 000000000..f5d47d50f --- /dev/null +++ b/scripts/fix-submodule-branches.sh @@ -0,0 +1,284 @@ +#!/usr/bin/env bash +set -euo pipefail + +DRY_RUN=0 +FORCE_SWITCH=0 +INIT_MISSING=0 +ROOT_DIR="." + +ERRORS=0 +COUNT_REPOS=0 +COUNT_SUBMODULES=0 +COUNT_FIXED=0 +COUNT_SKIPPED=0 +COUNT_ALREADY_OK=0 +COUNT_OTHER_BRANCH=0 +COUNT_INITIALIZED=0 +COUNT_DIRTY=0 + +log() { + printf '%s\n' "$*" +} + +warn() { + printf 'WARN: %s\n' "$*" >&2 +} + +err() { + printf 'ERROR: %s\n' "$*" >&2 +} + +on_error() { + local exit_code=$? + err "Abbruch in Zeile $1 mit Exit-Code $exit_code" + exit "$exit_code" +} + +trap 'on_error $LINENO' ERR + +run_cmd() { + if [[ "$DRY_RUN" -eq 1 ]]; then + printf '[dry-run] ' + printf '%q ' "$@" + printf '\n' + return 0 + fi + "$@" +} + +is_dirty_repo() { + local repo_dir="$1" + [[ -n "$(git -C "$repo_dir" status --porcelain 2>/dev/null || true)" ]] +} + +get_default_branch() { + local repo_dir="$1" + local head_ref default_branch + + head_ref="$(git -C "$repo_dir" symbolic-ref -q refs/remotes/origin/HEAD 2>/dev/null || true)" + if [[ -n "$head_ref" ]]; then + default_branch="${head_ref#refs/remotes/origin/}" + printf '%s\n' "$default_branch" + return 0 + fi + + git -C "$repo_dir" fetch origin >/dev/null 2>&1 || true + + head_ref="$(git -C "$repo_dir" symbolic-ref -q refs/remotes/origin/HEAD 2>/dev/null || true)" + if [[ -n "$head_ref" ]]; then + default_branch="${head_ref#refs/remotes/origin/}" + printf '%s\n' "$default_branch" + return 0 + fi + + default_branch="$(git -C "$repo_dir" remote show origin 2>/dev/null | sed -n 's/.*HEAD branch: //p' | head -n1 || true)" + if [[ -n "$default_branch" ]]; then + printf '%s\n' "$default_branch" + return 0 + fi + + return 1 +} + +init_submodule_if_needed() { + local repo_dir="$1" + local sub_path="$2" + local submodule_abs="$repo_dir/$sub_path" + + if [[ -d "$submodule_abs/.git" || -f "$submodule_abs/.git" ]]; then + return 0 + fi + + if [[ "$INIT_MISSING" -ne 1 ]]; then + return 1 + fi + + log " Initialisiere fehlendes Submodule: $sub_path" + if run_cmd git -C "$repo_dir" submodule update --init -- "$sub_path"; then + COUNT_INITIALIZED=$((COUNT_INITIALIZED + 1)) + return 0 + fi + + return 1 +} + +checkout_target_branch() { + local submodule_abs="$1" + local resolved_branch="$2" + + run_cmd git -C "$submodule_abs" fetch origin "$resolved_branch" >/dev/null 2>&1 || true + + if git -C "$submodule_abs" show-ref --verify --quiet "refs/heads/$resolved_branch"; then + log " Checke lokalen Branch '$resolved_branch' aus" + run_cmd git -C "$submodule_abs" checkout "$resolved_branch" + return 0 + fi + + if git -C "$submodule_abs" show-ref --verify --quiet "refs/remotes/origin/$resolved_branch"; then + log " Erzeuge Tracking-Branch '$resolved_branch' von origin/$resolved_branch" + run_cmd git -C "$submodule_abs" checkout -b "$resolved_branch" --track "origin/$resolved_branch" + return 0 + fi + + return 1 +} + +process_repo() { + local repo_dir="$1" + local gitmodules_file="$repo_dir/.gitmodules" + + [[ -f "$gitmodules_file" ]] || return 0 + + COUNT_REPOS=$((COUNT_REPOS + 1)) + log "Prüfe Repository: $repo_dir" + + local entries=() + while IFS= read -r line; do + entries+=("$line") + done < <(git -C "$repo_dir" config -f "$gitmodules_file" --get-regexp '^submodule\..*\.path$' 2>/dev/null || true) + + local entry key sub_path sub_name branch submodule_abs current_branch resolved_branch + for entry in "${entries[@]}"; do + COUNT_SUBMODULES=$((COUNT_SUBMODULES + 1)) + + key="${entry%% *}" + sub_path="${entry#* }" + + sub_name="${key#submodule.}" + sub_name="${sub_name%.path}" + submodule_abs="$repo_dir/$sub_path" + + if [[ ! -e "$submodule_abs" ]]; then + if [[ "$INIT_MISSING" -eq 1 ]]; then + log " Submodule '$sub_name' ($sub_path): Pfad fehlt, versuche Initialisierung" + else + err "Submodule '$sub_name' ($sub_path): Pfad existiert nicht" + ERRORS=1 + COUNT_SKIPPED=$((COUNT_SKIPPED + 1)) + continue + fi + fi + + if ! init_submodule_if_needed "$repo_dir" "$sub_path"; then + err "Submodule '$sub_name' ($sub_path): nicht initialisiert" + ERRORS=1 + COUNT_SKIPPED=$((COUNT_SKIPPED + 1)) + continue + fi + + if is_dirty_repo "$submodule_abs"; then + warn "Submodule '$sub_name' ($sub_path): uncommitted changes vorhanden, überspringe" + COUNT_DIRTY=$((COUNT_DIRTY + 1)) + COUNT_SKIPPED=$((COUNT_SKIPPED + 1)) + process_repo "$submodule_abs" + continue + fi + + branch="$(git -C "$repo_dir" config -f "$gitmodules_file" --get "submodule.${sub_name}.branch" 2>/dev/null || true)" + + if [[ -n "$branch" ]]; then + resolved_branch="$branch" + log " Submodule '$sub_name' ($sub_path): branch aus .gitmodules = $resolved_branch" + else + if resolved_branch="$(get_default_branch "$submodule_abs")"; then + log " Submodule '$sub_name' ($sub_path): kein branch in .gitmodules, nehme Default-Branch = $resolved_branch" + else + err "Submodule '$sub_name' ($sub_path): weder branch in .gitmodules noch Default-Branch von origin ermittelbar" + ERRORS=1 + COUNT_SKIPPED=$((COUNT_SKIPPED + 1)) + process_repo "$submodule_abs" + continue + fi + fi + + current_branch="$(git -C "$submodule_abs" symbolic-ref --short -q HEAD 2>/dev/null || true)" + + if [[ -n "$current_branch" ]]; then + if [[ "$current_branch" == "$resolved_branch" ]]; then + log " Bereits auf richtigem Branch: $current_branch" + COUNT_ALREADY_OK=$((COUNT_ALREADY_OK + 1)) + else + log " Auf anderem Branch: $current_branch" + if [[ "$FORCE_SWITCH" -eq 1 ]]; then + log " --force-switch aktiv, wechsle auf '$resolved_branch'" + if checkout_target_branch "$submodule_abs" "$resolved_branch"; then + COUNT_FIXED=$((COUNT_FIXED + 1)) + else + err "Submodule '$sub_name' ($sub_path): Branch '$resolved_branch' existiert weder lokal noch als origin/$resolved_branch" + ERRORS=1 + COUNT_SKIPPED=$((COUNT_SKIPPED + 1)) + fi + else + log " Keine Änderung, da nur detached HEADs umgestellt werden" + COUNT_OTHER_BRANCH=$((COUNT_OTHER_BRANCH + 1)) + fi + fi + else + log " Detached HEAD erkannt" + if checkout_target_branch "$submodule_abs" "$resolved_branch"; then + COUNT_FIXED=$((COUNT_FIXED + 1)) + else + err "Submodule '$sub_name' ($sub_path): Branch '$resolved_branch' existiert weder lokal noch als origin/$resolved_branch" + ERRORS=1 + COUNT_SKIPPED=$((COUNT_SKIPPED + 1)) + fi + fi + + process_repo "$submodule_abs" + done +} + +usage() { + cat <<'EOF' +Usage: + fix-submodule-branches-prod.sh [options] [root-dir] + +Optionen: + -n, --dry-run Nur anzeigen, was getan würde + --force-switch Auch von falschem Branch auf Ziel-Branch wechseln + --init-missing Nicht initialisierte Submodule automatisch initen + -h, --help Hilfe anzeigen +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + -n|--dry-run) + DRY_RUN=1 + ;; + --force-switch) + FORCE_SWITCH=1 + ;; + --init-missing) + INIT_MISSING=1 + ;; + -h|--help) + usage + exit 0 + ;; + *) + ROOT_DIR="$1" + ;; + esac + shift +done + +process_repo "$ROOT_DIR" + +log "" +log "Zusammenfassung:" +log " Repositories mit .gitmodules geprüft: $COUNT_REPOS" +log " Submodule insgesamt: $COUNT_SUBMODULES" +log " Detached/falsch Branch gefixt: $COUNT_FIXED" +log " Bereits korrekt auf Branch: $COUNT_ALREADY_OK" +log " Auf anderem Branch belassen: $COUNT_OTHER_BRANCH" +log " Initialisierte Submodule: $COUNT_INITIALIZED" +log " Dirty Submodule übersprungen: $COUNT_DIRTY" +log " Übersprungen/Fehler: $COUNT_SKIPPED" + +if [[ "$ERRORS" -ne 0 ]]; then + log "Status: Fertig mit Fehlern" + exit 1 +fi + +log "Status: Fertig ohne Fehler" \ No newline at end of file