diff --git a/local/bin/git-update-dirs b/local/bin/git-update-dirs index 168474d..c83750c 100755 --- a/local/bin/git-update-dirs +++ b/local/bin/git-update-dirs @@ -8,39 +8,688 @@ # Copyright (c) 2023 Ismo Vuorinen. All Rights Reserved. # License: MIT -set -euo pipefail +set -uo pipefail -# Enable verbosity with VERBOSE=1 -VERBOSE="${VERBOSE:-0}" +# 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() { - [ "$VERBOSE" -eq 1 ] && echo "$1" + 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=$1 - ( - cd "$dir" || exit - msg "Updating $dir" - git pull --rebase --autostash --prune - ) + 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 - echo "Done." - echo "" + # 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 "$@" diff --git a/local/bin/git-update-dirs.md b/local/bin/git-update-dirs.md new file mode 100644 index 0000000..8916fda --- /dev/null +++ b/local/bin/git-update-dirs.md @@ -0,0 +1,116 @@ +# git-update-dirs + +A tool that efficiently updates all Git repositories in subdirectories +of the current folder. + +## Overview + +`git-update-dirs` scans the current directory for Git repositories +and updates them with: + +- Fast parallel execution +- Intelligent error handling +- Progress visualization +- Detailed logging +- Optional branch cleanup + +## Installation + +Place the script in your PATH and make it executable: + +```bash +# Using wget +wget -O ~/bin/git-update-dirs https://raw.githubusercontent.com/ivuorinen/dotfiles/main/local/bin/git-update-dirs +chmod +x ~/bin/git-update-dirs + +# Or simply copy the script to a location in your PATH +cp git-update-dirs ~/bin/ +chmod +x ~/bin/git-update-dirs +``` + +## Usage + +```text +Usage: git-update-dirs [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 + +Basic usage to update all repositories: + +```bash +git-update-dirs +``` + +Update with detailed output: + +```bash +git-update-dirs --verbose +``` + +Exclude specific directories: + +```bash +git-update-dirs --exclude node_modules --exclude vendor +``` + +Update and clean up merged branches: + +```bash +git-update-dirs --cleanup +``` + +Use options from a configuration file: + +```bash +git-update-dirs --config ~/.gitupdate.conf +``` + +## Configuration File + +You can create a configuration file to store your preferred options: + +```text +# Example ~/.gitupdate.conf +verbose +exclude node_modules +exclude vendor +cleanup +log ~/.gitupdate.log +``` + +## Features + +- **Smart Updates**: Uses `--rebase --autostash --prune` + for clean updates +- **Error Handling**: Skips repositories with conflicts or + untracked files that would be overwritten +- **Visual Progress**: Shows a progress bar with current status +- **Repository Management**: Optionally cleans up merged branches +- **Detailed Logging**: Records all operations with timestamps + +## License + +[MIT License][MIT] - Copyright 2023 Ismo Vuorinen + +[MIT]: https://opensource.org/license/mit/ + +