Last active
July 5, 2025 14:03
-
-
Save literallylara/3ea9436cdef9a7d98a0124f76e58547f to your computer and use it in GitHub Desktop.
Batch pull git repositories recursively
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
| #!/bin/sh | |
| # @author literallylara | |
| # USE AT YOUR OWN RISK! | |
| # Inspired by https://gist.github.com/guitarrapc/2623623a0e1bc7fe86b5cf56e0c70d88 | |
| set -e # Exit on error | |
| set -u # Treat unset variables as an error | |
| MAX_DEPTH=-1 | |
| FORCE=false | |
| NO_STASH=false | |
| RECURSIVE=false | |
| VERBOSE=false | |
| DRY_RUN=false | |
| COL_RED="\e[1;31m" | |
| COL_GREEN="\e[1;32m" | |
| COL_YELLOW="\e[1;33m" | |
| COL_RESET="\e[0m" | |
| COL_GRAY="\e[1;30m" | |
| print_help() { | |
| echo "Usage: $(basename "$0") [OPTIONS]" | |
| echo " --force, -f git reset before pulling, after stashing" | |
| echo " --no-stash Do not stash changes before pulling" | |
| echo " --recursive, -r Walk all non-git directories recursively" | |
| echo " --max-depth, -d <depth> Set a maximum recursion depth" | |
| echo " --verbose Show detailed output" | |
| echo " --dry-run Simulate changes, except for fetching" | |
| echo " --help, -h Show this help message" | |
| } | |
| log() { | |
| color="${2:-COL_GRAY}" | |
| printf "%b%s%b\n" "$color" "$1" "$COL_RESET" | |
| } | |
| log_error() { | |
| printf "%bERROR %s%b\n" "${COL_RED}" "$*" "${COL_RESET}" 1>&2 | |
| } | |
| while [ $# -gt 0 ]; do | |
| case $1 in | |
| --force) | |
| FORCE=true | |
| ;; | |
| --no-stash) | |
| NO_STASH=true | |
| ;; | |
| --recursive) | |
| RECURSIVE=true | |
| ;; | |
| --max-depth) | |
| if [ -n "$2" ] && [ "$2" -gt 0 ] 2>/dev/null; then | |
| MAX_DEPTH="$2" | |
| shift | |
| else | |
| echo "Max depth must be followed by a positive integer." | |
| exit 1 | |
| fi | |
| ;; | |
| --verbose) | |
| VERBOSE=true | |
| ;; | |
| --dry-run) | |
| DRY_RUN=true | |
| log "== DRY RUN ENABLED - no changes will be made ==" "${COL_YELLOW}" | |
| ;; | |
| --help | -h) | |
| print_help | |
| exit 0 | |
| ;; | |
| *) | |
| echo "Unknown option: $1" | |
| print_help | |
| exit 1 | |
| ;; | |
| esac | |
| shift | |
| done | |
| get_repo_hash() { | |
| repo_name="$1" | |
| echo "$repo_name" | sha256sum | cut -c1-8 | |
| } | |
| get_repo_name() { | |
| origin="$1" | |
| branch="$2" | |
| repo_name=$(basename "$origin" .git) | |
| user_name=$(basename "$(dirname "$origin")" | sed 's/[^:]*://') | |
| echo "$user_name/$repo_name:$branch" | |
| } | |
| get_origin() { | |
| path="$1" | |
| if [ -d "$path/.git" ]; then | |
| origin=$(git -C "$path" config --get remote.origin.url 2>/dev/null) | |
| origin=${origin#https:\/\/} | |
| origin=${origin#http:\/\/} | |
| origin=${origin%/} | |
| else | |
| origin="" | |
| fi | |
| echo "$origin" | |
| } | |
| get_num_commits_behind() { | |
| path="$1" | |
| if [ -d "$path/.git" ]; then | |
| git -C "$path" rev-list --count 'HEAD..@{u}' 2>/dev/null || echo '?' | |
| fi | |
| } | |
| get_tracked_changes() { | |
| path="$1" | |
| if [ -d "$path/.git" ]; then | |
| git -C "$path" status --porcelain 2>/dev/null | grep '^[ MADRCU]' || true | sed 's/^/ /' | |
| fi | |
| } | |
| get_new_commits_log() { | |
| path="$1" | |
| if [ -d "$path/.git" ]; then | |
| git -C "$path" log --pretty=format:'%h|%s' 'HEAD..@{u}' 2>/dev/null | |
| fi | |
| } | |
| get_branch() { | |
| path="$1" | |
| if [ -d "$path/.git" ]; then | |
| git -C "$path" rev-parse --abbrev-ref HEAD 2>/dev/null | |
| fi | |
| } | |
| escape_path() { | |
| path="$1" | |
| # escaped_path="${path// /\\\\040}" | |
| escaped_path=$(echo "$path" | sed 's/ /\\\\040/g') | |
| echo "$escaped_path" | |
| } | |
| current_depth=0 | |
| pull_recursive() { | |
| # Ensure we are not started from a git repository | |
| if [ -d ".git" ]; then | |
| log_error "This script should not be run in a git repository. Please run it in a parent directory." | |
| exit 1 | |
| fi | |
| for dir in ./*/; do | |
| path=$(cd "$dir" && pwd) | |
| path_escaped="$(escape_path "$path")" | |
| # If not a git repository, walk recursively (if enabled) | |
| if [ ! -d "$path/.git" ]; then | |
| if [ "$RECURSIVE" = true ] && { [ "$MAX_DEPTH" -eq -1 ] || [ $current_depth -lt "$MAX_DEPTH" ]; }; then | |
| current_depth=$((current_depth + 1)) | |
| (cd "$path" && pull_recursive) | |
| current_depth=$((current_depth - 1)) | |
| else | |
| log "SKIP $path_escaped" "${COL_GRAY}" | |
| fi | |
| continue | |
| fi | |
| # Gather basic information about the repository | |
| { | |
| origin="$(get_origin "$path")" | |
| branch=$(get_branch "$path") | |
| repo_name="$(get_repo_name "$origin" "$branch")" | |
| # repo_hash="$(get_repo_hash "$repo_name")" | |
| } | |
| # Fetch | |
| { | |
| if [ "$VERBOSE" = true ]; then | |
| log "FETCH $repo_name ($path_escaped)" "${COL_GRAY}" | |
| else | |
| log "FETCH $repo_name" "${COL_GRAY}" | |
| fi | |
| if ! git -C "$path" fetch --quiet 2>/dev/null; then | |
| log_error "Failed to fetch: $repo_name" | |
| continue | |
| fi | |
| } | |
| num_commits_behind=$(get_num_commits_behind "$path") | |
| # If we are behind: stash, (reset), pull, unstash | |
| if [ "$num_commits_behind" -gt 0 ]; then | |
| # Gather tracked changes | |
| tracked_changes=$(get_tracked_changes "$path") | |
| num_tracked_changes=$(echo "$tracked_changes" | grep -c ' ' || true) | |
| # Stash | |
| if [ "$NO_STASH" = false ] && [ "$num_tracked_changes" -gt 0 ]; then | |
| if [ "$VERBOSE" = true ]; then | |
| echo "$tracked_changes" | while IFS= read -r line; do | |
| log "STASH $repo_name $line" "${COL_YELLOW}" | |
| done | |
| else | |
| log "STASH($num_tracked_changes) $repo_name" "${COL_YELLOW}" | |
| fi | |
| if [ "$DRY_RUN" != true ] && ! git -C "$path" stash --quiet 2>/dev/null; then | |
| log_error "Failed to stash: $repo_name" | |
| continue | |
| fi | |
| fi | |
| # Reset (if forced) | |
| if [ "$FORCE" = "true" ]; then | |
| log "RESET $repo_name" "${COL_YELLOW}" | |
| if ! git -C "$path" reset --hard --quiet 2>/dev/null; then | |
| log_error "Failed to reset: $path_escaped" | |
| continue | |
| fi | |
| fi | |
| # Pull | |
| { | |
| if [ "$VERBOSE" = false ]; then | |
| log "PULL($num_commits_behind) $repo_name" "${COL_GREEN}" | |
| else | |
| new_commits_log=$(get_new_commits_log "$path") | |
| echo "$new_commits_log" | while IFS= read -r line; do | |
| log "PULL $repo_name $line" "${COL_GREEN}" | |
| done | |
| fi | |
| if [ "$DRY_RUN" != true ] && ! git -C "$path" pull --quiet 2>/dev/null; then | |
| log_error "Failed to pull: $repo_name" | |
| continue | |
| fi | |
| } | |
| # unstash | |
| if [ "$NO_STASH" = false ] && [ "$num_tracked_changes" -gt 0 ]; then | |
| log "UNSTASH $repo_name" "${COL_YELLOW}" | |
| if [ "$DRY_RUN" != true ] && ! git -C "$path" stash apply --quiet 2>/dev/null; then | |
| log_error "Failed to apply stash: $repo_name" | |
| continue | |
| fi | |
| fi | |
| fi | |
| done | |
| } | |
| pull_recursive |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Minimal version: