Last active
July 7, 2025 16:19
-
-
Save dzogrim/1200bf4171b69ea9ded23e53b31746cb to your computer and use it in GitHub Desktop.
This script scans and maintains Git repositories under a given base directory
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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