#!/usr/bin/env bash # # Updates subfolders git projects. # # Actions taken: pull with rebase, autostashes own changes # and prunes branches no longer in the remote. # # Copyright (c) 2023 Ismo Vuorinen. All Rights Reserved. # License: MIT set -uo pipefail # Script version VERSION="1.0.0" # Default settings VERBOSE=0 QUIET=0 EXCLUDE_DIRS="" CLEANUP=0 CONFIG_FILE="" LOG_FILE="" # Define color variables if terminal supports it if [[ -t 1 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' NC='\033[0m' # No Color else RED='' GREEN='' YELLOW='' BLUE='' CYAN='' NC='' fi # Counters TOTAL=0 SUCCESS=0 FAILED=0 CONFLICTS=0 UPDATED=0 PROCESSED=0 SKIPPED=0 UNTRACKED=0 UNMERGED=0 BRANCHES_CLEANED=0 # Function to display help message show_help() { BIN=$(basename "$0") cat << EOF Usage: $BIN [OPTIONS] Updates all git repositories in subdirectories. Options: --help, -h Display this help message and exit --version, -v Display version information and exit --verbose Display detailed output --quiet, -q Suppress all output except errors --exclude DIR Exclude directory from updates (can be used multiple times) --cleanup Remove local branches that have been merged into current branch --config FILE Read options from configuration file --log FILE Log details and errors to FILE Environment variables: VERBOSE Set to 1 to enable verbose output EXCLUDE_DIRS Space-separated list of directories to exclude Examples: $BIN Update all git repositories $BIN --verbose Update with detailed output $BIN --exclude node_modules --exclude vendor Update repositories but skip node_modules and vendor dirs $BIN --cleanup Update and clean up merged branches $BIN --config ~/.gitupdate.conf Use options from config file EOF exit 0 } # Function to display version show_version() { echo "$(basename "$0") version $VERSION" exit 0 } # Function to log messages # $1 - level (string: INFO, WARNING, ERROR) # $2 - message (string) log() { local level message timestamp level="$1" message="$2" timestamp=$(date +"%Y-%m-%d %H:%M:%S") if [[ -n "$LOG_FILE" ]]; then echo "[$timestamp] [$level] $message" >> "$LOG_FILE" fi # For errors, also log to stderr if in verbose mode if [[ "$level" == "ERROR" && "$VERBOSE" -eq 1 && "$QUIET" -eq 0 ]]; then echo -e "${RED}[$timestamp] [$level] $message${NC}" >&2 fi } # Process command-line arguments process_args() { while [[ $# -gt 0 ]]; do case "$1" in --help | -h) show_help ;; --version | -v) show_version ;; --verbose) VERBOSE=1 ;; --quiet | -q) QUIET=1 ;; --exclude) if [[ -n "$2" ]]; then EXCLUDE_DIRS="$EXCLUDE_DIRS $2" shift else echo "Error: --exclude requires a directory argument" >&2 exit 1 fi ;; --cleanup) CLEANUP=1 ;; --config) if [[ -n "$2" && -f "$2" ]]; then CONFIG_FILE="$2" shift else echo "Error: --config requires a valid file argument" >&2 exit 1 fi ;; --log) if [[ -n "$2" ]]; then LOG_FILE="$2" shift else echo "Error: --log requires a file argument" >&2 exit 1 fi ;; *) echo "Unknown option: $1" >&2 echo "Use --help for usage information" >&2 exit 1 ;; esac shift done # Process config file if specified if [[ -n "$CONFIG_FILE" && -f "$CONFIG_FILE" ]]; then log "INFO" "Reading configuration from $CONFIG_FILE" while IFS= read -r line || [[ -n "$line" ]]; do # Skip comments and empty lines [[ "$line" =~ ^[[:space:]]*# ]] && continue [[ -z "${line// /}" ]] && continue # Process each option from the config file option=$(echo "$line" | awk '{print $1}') value=$(echo "$line" | cut -d' ' -f2-) case "$option" in exclude) EXCLUDE_DIRS="$EXCLUDE_DIRS $value" ;; verbose) VERBOSE=1 ;; quiet) QUIET=1 ;; cleanup) CLEANUP=1 ;; log) LOG_FILE="$value" ;; *) log "WARNING" "Unknown option in config file: $option" ;; esac done < "$CONFIG_FILE" fi # Environment variables override command-line options [[ -n "${VERBOSE:-}" && "$VERBOSE" -eq 1 ]] && VERBOSE=1 # shellcheck disable=SC2269 [[ -n "${EXCLUDE_DIRS:-}" ]] && EXCLUDE_DIRS="${EXCLUDE_DIRS}" # Initialize log file if specified if [[ -n "$LOG_FILE" ]]; then # Create log directory if it doesn't exist mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true # Initialize log file echo "[$(date +"%Y-%m-%d %H:%M:%S")] [INFO] Started git-update-dirs version $VERSION" > "$LOG_FILE" fi } # Terminal width for progress bar TERM_WIDTH=$(tput cols 2> /dev/null || echo 120) PROGRESS_WIDTH=$((TERM_WIDTH - 40)) MAX_DIR_LENGTH=$((TERM_WIDTH - PROGRESS_WIDTH - 25)) # Add 5 for extra padding # Last status message, used for clearing properly LAST_STATUS_LENGTH=0 # Function to print messages if VERBOSE is enabled # $1 - message (string) msg() { local message message="$1" if [[ "$VERBOSE" -eq 1 && "$QUIET" -eq 0 ]]; then echo "$message" [[ -n "$LOG_FILE" ]] && log "INFO" "$message" elif [[ -n "$LOG_FILE" ]]; then log "DEBUG" "$message" fi } # Function to print normal output unless QUIET is enabled # $1 - message (string) print() { local message message="$1" if [[ "$QUIET" -eq 0 ]]; then echo -e "$message" [[ -n "$LOG_FILE" ]] && log "INFO" "$message" elif [[ -n "$LOG_FILE" ]]; then log "INFO" "$message" fi } # Function to display progress bar # $1 - current (int) # $2 - total (int) # $3 - status message (string) show_progress() { [[ "$QUIET" -eq 1 ]] && return local current total status percent filled empty current=$1 total=$2 status=$3 # If TERM_WIDTH is less than LAST_STATUS_LENGTH set TERM_WIDTH # to it. if [[ $TERM_WIDTH -lt $LAST_STATUS_LENGTH ]]; then TERM_WIDTH=$LAST_STATUS_LENGTH fi # Clear the entire line before updating to avoid artifacts printf "\r%-${TERM_WIDTH}s" " " # Avoid division by zero if [[ "$total" -eq 0 ]]; then percent=0 else percent=$((current * 100 / total)) fi filled=$((percent * PROGRESS_WIDTH / 100)) # Ensure filled doesn't exceed PROGRESS_WIDTH [[ $filled -gt $PROGRESS_WIDTH ]] && filled=$PROGRESS_WIDTH empty=$((PROGRESS_WIDTH - filled)) # Truncate status message if too long if [[ ${#status} -gt $MAX_DIR_LENGTH ]]; then status="...${status:$((${#status} - MAX_DIR_LENGTH + 4))}" fi # Pad the status message to ensure consistent width and add extra space printf -v padded_status "%-${MAX_DIR_LENGTH}s" "$status" # Create and display the progress bar with fixed width for percentage and colors printf "\r[${BLUE}%s${NC}%s] ${GREEN}%3d%%${NC} ${CYAN}%s${NC}" \ "$(printf '#%.0s' $(seq 1 $filled))" \ "$(printf ' %.0s' $(seq 1 $empty))" \ "$percent" \ "$padded_status" # Store the length of the current status LAST_STATUS_LENGTH=${#status} # Log progress if logging is enabled [[ -n "$LOG_FILE" ]] && log "DEBUG" "Progress: $percent% - $status" } # Is the directory path excluded? # $1: Directory path # Return 0 if the directory should be skipped, 1 otherwise excluded_path() { local dir home dir="$(realpath "$1")" home="$(realpath "$HOME")" # Check if directory should be excluded for exclude in $EXCLUDE_DIRS; do # Check for parts of the directory name if [[ "$dir" == *"$exclude"* ]] || [[ "$dir" == "$exclude" ]]; then msg "Skipping excluded directory: $dir" return 0 fi # Run only if home is not empty if [[ -n "$home" ]]; then # Remove home directory from path relative_dir="${dir/"$home"/}" # Check if we should exclude based on relative paths based on the home directory if [[ "$relative_dir" == *"$exclude"* ]] || [[ "$relative_dir" == "$exclude" ]]; then msg "Skipping excluded relative directory: $dir" return 0 fi fi done # Check if it's a git repository if [[ ! -d "$dir/.git" ]]; then msg "Skipping non-git directory: $dir" return 0 fi return 1 } # Function to count git repositories count_git_repos() { local count=0 for dir in */; do if ! excluded_path "$dir"; then ((count++)) fi done echo $count } # Check for unmerged files or conflicts in a git repository # Returns 0 if there are unmerged files, 1 otherwise has_unmerged_files() { git ls-files --unmerged | grep -q "^" \ && return 0 || return 1 } # Check for clean working directory # Returns 0 if working directory is clean, 1 otherwise is_repo_clean() { git diff --quiet \ && git diff --cached --quiet \ && return 0 || return 1 } # Function to clean up local branches that have been merged # Returns the number of branches cleaned cleanup_branches() { local cleaned=0 local current_branch output current_branch=$(git symbolic-ref --short HEAD 2>/dev/null) # Skip branch cleanup if we're not on a main branch if [[ ! "$current_branch" =~ ^(master|main|develop)$ ]]; then msg "Skipping branch cleanup: not on a main branch ($current_branch)" return 0 fi # Get list of merged branches, excluding current branch, master, main, and develop output=$(git branch --merged | grep -v -E "^\*|master|main|develop" | sed 's/^[[:space:]]*//') if [[ -n "$output" ]]; then if [[ "$VERBOSE" -eq 1 ]]; then msg "Cleaning up merged branches in $(pwd):" echo "$output" | while read -r branch; do msg " - $branch" done fi # Delete branches for branch in $output; do if [[ -n "$branch" ]]; then if git branch -d "$branch" &>/dev/null; then ((cleaned++)) log "INFO" "Deleted merged branch $branch in $(pwd)" else log "WARNING" "Failed to delete branch $branch in $(pwd)" fi fi done fi return $cleaned } # Function to update a git repository # $1 - directory (string) update_repo() { local dir output exit_status git_args current_branch \ remote_name cleaned_branches dir="$1" log "INFO" "Processing repository: $dir" # Increment the processed counter ((PROCESSED++)) # Show progress before starting the operation show_progress "$PROCESSED" "$TOTAL" "${dir%/}" cd "$dir" 2>/dev/null || { log "ERROR" "Could not enter directory $dir" echo -e "\n${RED}Error: Could not enter directory $dir${NC}" >&2 ((FAILED++)) return 1 } # If there are no remotes, skip if ! git remote -v &> /dev/null; then log "INFO" "Skipping directory with no remotes: $dir" msg "Skipping directory with no remotes: $dir" ((SKIPPED++)) cd - >/dev/null || true return 1 fi # Get current branch name current_branch=$(git symbolic-ref --short HEAD 2>/dev/null) if [[ -z "$current_branch" ]]; then log "INFO" "Skipping repository in detached HEAD state: $dir" msg "Skipping repository in detached HEAD state: $dir" ((SKIPPED++)) cd - >/dev/null || true return 1 fi # Check if current branch has tracking information eval "git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null" &>/dev/null || { log "INFO" "Skipping branch '$current_branch' without tracking info in $dir" msg "Skipping branch '$current_branch' without tracking info in $dir" ((SKIPPED++)) cd - >/dev/null || true return 1 } # Check if remote is accessible remote_name=$(git config --get branch."$current_branch".remote) if [[ -n "$remote_name" ]]; then if ! git ls-remote --exit-code "$remote_name" &>/dev/null; then log "WARNING" "Skipping repository with inaccessible remote '$remote_name': $dir" msg "Skipping repository with inaccessible remote: $dir" ((SKIPPED++)) cd - >/dev/null || true return 1 fi fi # Check for unmerged files before attempting pull if has_unmerged_files; then log "WARNING" "Skipping repository with unmerged files: $dir" msg "Skipping repository with unmerged files: $dir" ((UNMERGED++)) cd - >/dev/null || true return 1 fi # Configure Git arguments based on verbosity git_args="--rebase --autostash --prune" if [[ "$VERBOSE" -eq 0 ]]; then git_args="$git_args --quiet" fi # Disable Git hints and set other environment variables export GIT_MERGE_AUTOEDIT=no export GIT_CONFIG_COUNT=4 export GIT_CONFIG_KEY_0="advice.skipHints" export GIT_CONFIG_VALUE_0="true" export GIT_CONFIG_KEY_1="advice.detachedHead" export GIT_CONFIG_VALUE_1="false" export GIT_CONFIG_KEY_2="advice.pushUpdateRejected" export GIT_CONFIG_VALUE_2="false" export GIT_CONFIG_KEY_3="advice.statusHints" export GIT_CONFIG_VALUE_3="false" # Capture the output of git pull if [[ "$VERBOSE" -eq 1 ]]; then # shellcheck disable=SC2086 output=$(git pull $git_args 2>&1) exit_status=$? # In verbose mode, show the git output [[ "$QUIET" -eq 0 ]] && echo -e "\n$output\n" log "DEBUG" "Git pull output: $output" else # In non-verbose mode, suppress normal output but capture errors # shellcheck disable=SC2086 output=$(git pull $git_args 2>&1) || { exit_status=$? } # If no error occurred, set exit_status to 0 exit_status=${exit_status:-0} fi # Unset environment variables unset GIT_MERGE_AUTOEDIT GIT_CONFIG_COUNT \ GIT_CONFIG_KEY_0 GIT_CONFIG_KEY_1 \ GIT_CONFIG_KEY_2 GIT_CONFIG_KEY_3 \ GIT_CONFIG_VALUE_0 GIT_CONFIG_VALUE_1 \ GIT_CONFIG_VALUE_2 GIT_CONFIG_VALUE_3 # Check for specific error conditions if echo "$output" | grep -q "Merge conflict"; then if [[ "$VERBOSE" -eq 1 ]]; then echo "" echo -e "${YELLOW}Merge conflict detected in $dir. Aborting update.${NC}" >&2 fi log "WARNING" "Merge conflict detected in $dir. Aborting update." git rebase --abort &> /dev/null || git merge --abort &> /dev/null || true ((CONFLICTS++)) elif echo "$output" | grep -q "unmerged files"; then if [[ "$VERBOSE" -eq 1 ]]; then echo "" echo -e "${YELLOW}Unmerged files detected in $dir. Aborting update.${NC}" >&2 fi log "WARNING" "Unmerged files detected in $dir. Aborting update." ((UNMERGED++)) elif echo "$output" | grep -q "untracked working tree files would be overwritten by merge"; then if [[ "$VERBOSE" -eq 1 ]]; then echo "" echo -e "${YELLOW}Untracked files would be overwritten in $dir. Aborting update.${NC}" >&2 fi log "WARNING" "Untracked files would be overwritten in $dir. Aborting update." ((UNTRACKED++)) elif [[ $exit_status -ne 0 ]]; then if [[ "$VERBOSE" -eq 1 || "$QUIET" -eq 0 ]]; then echo "" echo -e "${RED}Error updating $dir${NC}" >&2 echo "$output" >&2 fi log "ERROR" "Failed to update $dir: $output" ((FAILED++)) else # Check if any changes were pulled if echo "$output" | grep -qE '(file changed|files changed|insertions|deletions)' \ || ! echo "$output" | grep -q "Already up to date."; then log "INFO" "Repository updated with changes: $dir" ((UPDATED++)) else log "INFO" "Repository already up to date: $dir" fi ((SUCCESS++)) # Clean up branches if requested if [[ "$CLEANUP" -eq 1 ]]; then cleaned_branches=$(cleanup_branches) if [[ $cleaned_branches -gt 0 ]]; then ((BRANCHES_CLEANED += cleaned_branches)) log "INFO" "Cleaned up $cleaned_branches merged branches in $dir" fi fi fi # Return to original directory cd - >/dev/null || true # Show progress after completion show_progress "$PROCESSED" "$TOTAL" "${dir%/} - Done" } # Main function to update all subfolder git repositories main() { local current_dir start_time end_time duration # Record start time start_time=$(date +%s) # Process command-line args before doing anything else process_args "$@" # Save current directory to return to it later current_dir=$(pwd) log "INFO" "Starting repository updates in $current_dir" # Count repositories and set TOTAL TOTAL=$(count_git_repos) print "Found $TOTAL git repositories to update" # Reset other counters PROCESSED=0 SUCCESS=0 FAILED=0 CONFLICTS=0 UPDATED=0 SKIPPED=0 UNTRACKED=0 UNMERGED=0 BRANCHES_CLEANED=0 # Process each repository for dir in */; do # Skip if excluded if excluded_path "$dir"; then continue fi update_repo "$dir" done # Return to original directory cd "$current_dir" || true # Clear the progress line completely [[ "$QUIET" -eq 0 ]] && printf "\r%-${TERM_WIDTH}s\r" " " # Calculate duration end_time=$(date +%s) duration=$((end_time - start_time)) minutes=$((duration / 60)) seconds=$((duration % 60)) # Format duration nicely if [[ $minutes -gt 0 ]]; then duration_str="${minutes}m ${seconds}s" else duration_str="${seconds}s" fi # Print summary unless quiet mode is enabled if [[ "$QUIET" -eq 0 ]]; then echo "" print "${GREEN}Summary: Updated $SUCCESS/$TOTAL repositories successfully in $duration_str.${NC}" print "${CYAN}Repositories with changes pulled: $UPDATED${NC}" if [[ $SKIPPED -gt 0 ]]; then print "${YELLOW}Skipped $SKIPPED repositories (no tracking branch or other issues).${NC}" fi if [[ $UNMERGED -gt 0 ]]; then print "${YELLOW}Skipped $UNMERGED repositories with unmerged files.${NC}" fi if [[ $UNTRACKED -gt 0 ]]; then print "${YELLOW}Skipped $UNTRACKED repositories with untracked files that would be overwritten.${NC}" fi if [[ $CONFLICTS -gt 0 ]]; then print "${YELLOW}Encountered merge conflicts in $CONFLICTS repositories.${NC}" fi if [[ $CLEANUP -eq 1 && $BRANCHES_CLEANED -gt 0 ]]; then print "${BLUE}Cleaned up $BRANCHES_CLEANED merged branches.${NC}" fi if [[ $FAILED -gt 0 ]]; then echo -e "${RED}Failed to update $FAILED repositories.${NC}" >&2 else print "${GREEN}Done.${NC}" fi fi # Log final summary if [[ -n "$LOG_FILE" ]]; then log "INFO" "Completed in $duration_str" log "INFO" "Summary: $SUCCESS/$TOTAL repositories updated successfully" log "INFO" "Repositories with changes pulled: $UPDATED" log "INFO" "Skipped: $SKIPPED, Unmerged: $UNMERGED, Untracked: $UNTRACKED, Conflicts: $CONFLICTS, Failed: $FAILED" if [[ $CLEANUP -eq 1 ]]; then log "INFO" "Branches cleaned up: $BRANCHES_CLEANED" fi fi # Return appropriate exit code [[ $FAILED -gt 0 ]] && return 1 || return 0 } # Call main with all arguments main "$@"