feat(bin): rewrote git-update-dirs

This commit is contained in:
2025-04-15 20:59:50 +03:00
parent 16311ee5b4
commit 4a9c9b4cb9
2 changed files with 777 additions and 12 deletions

View File

@@ -8,39 +8,688 @@
# Copyright (c) 2023 Ismo Vuorinen. All Rights Reserved.
# License: MIT <https://opensource.org/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 "$@"

View File

@@ -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/
<!-- vim: set ft=markdown cc=80 : -->