Skip to content

Instantly share code, notes, and snippets.

@literallylara
Last active July 5, 2025 14:03
Show Gist options
  • Select an option

  • Save literallylara/3ea9436cdef9a7d98a0124f76e58547f to your computer and use it in GitHub Desktop.

Select an option

Save literallylara/3ea9436cdef9a7d98a0124f76e58547f to your computer and use it in GitHub Desktop.
Batch pull git repositories recursively
#!/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
@literallylara
Copy link
Author

Minimal version:

find -maxdepth 3 -type d -name '.git' -exec sh -c 'cd {}/..; echo "$PWD"; git stash -q; git pull -q; git stash apply -q 2>/dev/null' \;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment