Skip to content

Instantly share code, notes, and snippets.

@dzogrim
Last active July 7, 2025 16:19
Show Gist options
  • Save dzogrim/1200bf4171b69ea9ded23e53b31746cb to your computer and use it in GitHub Desktop.
Save dzogrim/1200bf4171b69ea9ded23e53b31746cb to your computer and use it in GitHub Desktop.
This script scans and maintains Git repositories under a given base directory
#!/usr/bin/env bash
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: 2025 Sรฉbastien L.
#
# Description:
# This script scans and maintains Git repositories under a base directory.
# Features:
# - Silent by default, verbose with '-v' or '--verbose'.
# - Dynamic discovery with '--discover'.
# - Force 'yes' with '--force', skip all prompts with '--skip'.
# - Restrict processing to 'main' branch with '--main-only'.
# - Fast 1-second timeout on remote ping (if possible).
# - IPv6 and dual-stack compatible.
# - CRON-safe, portable, and SIGINT-clean.
#
# Version: will be printed at runtime
VERSION_NUMBER="1.0.1"
VERSION_DATE="2025-07-07"
VERSION="${VERSION_NUMBER} (${VERSION_DATE//-/})"
# Trap Ctrl+C
trap 'echo -e "\nโŒ Interrupted. Exiting."; exit 130' INT
# Personal settings
BASE_DIR="$HOME/Documents/Workspaces"
REMOTE_SRV="git.toto.fqdn"
REPOS_LIST=(
"admin"
"deploy"
"docs"
"another_one"
)
# Init. default settings
VERBOSE=false
AUTO_SKIP=false
AUTO_FORCE=false
DISCOVER=false
MAIN_ONLY=false
REMOTE_REACHABLE=true
# Parse options
while [[ $# -gt 0 ]]; do
case $1 in
-v|--verbose) VERBOSE=true ;;
--skip) AUTO_SKIP=true ;;
--force) AUTO_FORCE=true ;;
--discover) DISCOVER=true ;;
--main-only) MAIN_ONLY=true ;;
-h|--help)
echo "Usage: $0 [-v|--verbose] [--skip] [--force] [--discover] [--main-only]"
echo "Version: $VERSION"
exit 0
;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
shift
done
# Check Git availability
if ! command -v git >/dev/null 2>&1; then
echo "โŒ Git is not installed." >&2
exit 1
fi
$VERBOSE && echo "๐Ÿ”ง Config: VERBOSE=$VERBOSE, FORCE=$AUTO_FORCE, SKIP=$AUTO_SKIP, DISCOVER=$DISCOVER, MAIN_ONLY=$MAIN_ONLY"
$VERBOSE && echo "๐Ÿ”ง Script version: $VERSION"
check_remote_reachability() {
local remote=$1
REMOTE_REACHABLE=true
if command -v ping6 >/dev/null 2>&1; then
PING_CMD="ping6"
elif command -v ping >/dev/null 2>&1; then
PING_CMD="ping -6"
else
echo "โŒ No IPv6 ping command found." >&2
REMOTE_REACHABLE=false
return
fi
if $PING_CMD --version 2>&1 | grep -q "GNU"; then
TIMEOUT_OPTION="-w 1"
else
TIMEOUT_OPTION=""
fi
if command -v gtimeout >/dev/null 2>&1; then
TIMEOUT_CMD="gtimeout 1s"
elif command -v timeout >/dev/null 2>&1; then
TIMEOUT_CMD="timeout 1s"
else
TIMEOUT_CMD=""
echo "โš ๏ธ No timeout command found. Ping may block if host is unreachable." >&2
fi
echo "๐Ÿ” Checking remote reachability: $remote"
# shellcheck disable=SC2086
if ! $TIMEOUT_CMD $PING_CMD -c 1 $TIMEOUT_OPTION "$remote" >/dev/null 2>&1; then
$VERBOSE && echo "โš ๏ธ Remote $remote is unreachable. Fetch operations will be skipped." >&2
REMOTE_REACHABLE=false
fi
}
check_remote_reachability "$REMOTE_SRV"
run_git_maintenance() {
local repo=$1
find .git/refs -name '.DS_Store' -delete || true
if ! git maintenance run --quiet 2>/dev/null; then
$VERBOSE && echo "โš ๏ธ Maintenance failed for $repo" >&2
fi
if ! git reflog expire --expire=now --all 2>/dev/null; then
$VERBOSE && echo "โš ๏ธ Failed to expire reflogs for $repo" >&2
fi
if ! git gc --prune=now --aggressive 2>/dev/null; then
$VERBOSE && echo "โš ๏ธ Garbage collection failed for $repo" >&2
fi
if ! git fsck --full --strict 2>/dev/null; then
$VERBOSE && echo "โš ๏ธ Integrity check failed for $repo" >&2
fi
}
if [ "$DISCOVER" = true ]; then
REPOS=()
while IFS= read -r -d '' dir; do
if [ -d "$dir/.git" ]; then
REPOS+=("$dir")
fi
done < <(find "$BASE_DIR" -mindepth 1 -maxdepth 1 -type d -print0 | sort -z)
else
REPOS=("${REPOS_LIST[@]/#/$BASE_DIR/}")
fi
for REPO_PATH in "${REPOS[@]}"; do
if [ ! -d "$REPO_PATH" ]; then
$VERBOSE && echo "โญ๏ธ Skipping missing repository: $REPO_PATH" >&2
continue
fi
REPO_NAME=$(basename "$REPO_PATH")
$VERBOSE && echo ""
$VERBOSE && echo "===== Processing $REPO_NAME ====="
cd "$REPO_PATH" || continue
if [ "$MAIN_ONLY" = true ]; then
current_branch=$(git rev-parse --abbrev-ref HEAD)
if [[ "$current_branch" != "main" ]]; then
$VERBOSE && echo "โญ๏ธ Skipping $REPO_NAME: not on 'main' branch."
continue
fi
fi
if [ "$REMOTE_REACHABLE" = true ]; then
if ! git fetch --prune --quiet 2>/dev/null; then
$VERBOSE && echo "โš ๏ธ Failed to fetch updates for $REPO_NAME" >&2
fi
fi
dangling_branches=$(git branch -vv | awk '/: gone]/{print $1}')
if [ -n "$dangling_branches" ]; then
$VERBOSE && echo "๐Ÿ—‘๏ธ Branches with deleted remote for $REPO_NAME:"
$VERBOSE && echo "$dangling_branches"
else
$VERBOSE && echo "โœ… No dangling branches for $REPO_NAME."
fi
merged_branches=$(git branch --merged | grep -vE '^\*|\b(main|master|develop)\b' | sed 's/^ *//')
if [ -n "$merged_branches" ]; then
$VERBOSE && echo "๐Ÿ—‘๏ธ Local merged branches that can be safely deleted for $REPO_NAME:"
$VERBOSE && echo "$merged_branches"
while IFS= read -r branch; do
if [ "$AUTO_FORCE" = true ]; then
confirm="y"
elif [ "$AUTO_SKIP" = true ]; then
confirm="n"
else
echo ""
echo "๐Ÿ—‘๏ธ Candidate for deletion: $branch"
read -t 3 -rp "Do you want to delete this branch? [y/N] (auto-N in 3s) " confirm || confirm="N"
fi
if [[ "$confirm" =~ ^[Yy]$ ]]; then
git branch -d "${branch}"
fi
done <<< "$merged_branches"
else
$VERBOSE && echo "โœ… No merged branches to delete for $REPO_NAME."
fi
run_git_maintenance "$REPO_NAME"
if [ "$VERBOSE" = false ]; then
echo -n "โ†’"
fi
done
$VERBOSE && echo ""
echo ""
echo "๐ŸŽ‰ All repositories have been processed successfully."
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment