Skip to content

Instantly share code, notes, and snippets.

@pythoninthegrass
Last active June 14, 2025 03:33
Show Gist options
  • Save pythoninthegrass/3b1119c05780110313bd8ca97af1defb to your computer and use it in GitHub Desktop.
Save pythoninthegrass/3b1119c05780110313bd8ca97af1defb to your computer and use it in GitHub Desktop.
Alternative to git archive that retains git tracked files/directories
#!/usr/bin/env bash
# shellcheck disable=SC2046,SC2164,SC2317
# Global variables
logged_in_user=""
logged_in_home=""
repo_dir=""
output_dir=""
progress_file=""
failed_file=""
total_count=0
max_jobs=0
verbose=false
force=false
# Initialize global variables
init_globals() {
# $USER
[[ -n $(logname >/dev/null 2>&1) ]] && logged_in_user=$(logname) || logged_in_user=$(whoami)
# $HOME
logged_in_home=$(eval echo "~${logged_in_user}")
# repo parent directory
repo_dir="${REPO_DIR:-${logged_in_home}/git}"
# output directory
output_dir="${OUTPUT_DIR:-/tmp}"
# Create output directory if it doesn't exist
mkdir -p "$output_dir"
# Create temporary files for progress tracking and failed repos
progress_file=$(mktemp)
failed_file=$(mktemp)
echo "0" > "$progress_file"
}
# Cleanup function
cleanup() {
rm -f "$progress_file" "$failed_file"
}
# Function to bundle a single repository
bundle_repo() {
local repo="$1"
local repo_name
repo_name=$(basename $(dirname "$repo"))
cd "$repo"
if git bundle create "${output_dir}/${repo_name}.bundle" --all >/dev/null 2>&1; then
# Success - just increment counter
local current
current=$(cat "$progress_file")
echo $((current + 1)) > "$progress_file"
else
# Failed - increment counter and log failure
echo "$repo_name" >> "$failed_file"
local current
current=$(cat "$progress_file")
echo $((current + 1)) > "$progress_file"
fi
}
# Verbose logging function
log_verbose() {
if [ "$verbose" = true ]; then
echo "[DEBUG] $*" >&2
fi
}
# Start progress monitoring in background
monitor_progress() {
local last_count=0
log_verbose "Starting progress monitor (total: $total_count)"
while [ "$last_count" -lt "$total_count" ]; do
local current_count
current_count=$(cat "$progress_file" 2>/dev/null || echo "0")
if [ "$current_count" != "$last_count" ]; then
printf "\rBundling repositories: %d/%d" "$current_count" "$total_count"
last_count="$current_count"
log_verbose "Progress: $current_count/$total_count"
fi
sleep 0.1
done
printf "\rBundling repositories: %d/%d" "$total_count" "$total_count"
log_verbose "Progress monitor finished"
}
# Backup function - creates bundles of all repositories
backup() {
echo "Starting git bundling!"
log_verbose "Repository directory: $repo_dir"
log_verbose "Output directory: $output_dir"
# Count total repositories first
total_count=$(find "$repo_dir" -maxdepth 2 -name "*.git" -type d | wc -l)
log_verbose "Found $total_count repositories"
if [ "$total_count" -eq 0 ]; then
echo "No repositories found in $repo_dir"
return 0
fi
# Export the function and variables for xargs
export -f bundle_repo log_verbose
export output_dir progress_file failed_file verbose
# Determine number of parallel jobs
if [ -n "$MAX_JOBS" ]; then
max_jobs="$MAX_JOBS"
else
# Auto-detect CPU cores, default to 4 if detection fails
max_jobs=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo "4")
# Cap at 8 for auto-detection to avoid overwhelming system
max_jobs=$((max_jobs > 8 ? 8 : max_jobs))
fi
log_verbose "Using $max_jobs parallel jobs"
# Start progress monitoring in background
monitor_progress &
monitor_pid=$!
log_verbose "Started progress monitor with PID: $monitor_pid"
# Run parallel bundling
log_verbose "Starting parallel bundling"
find "$repo_dir" -maxdepth 2 -name "*.git" -type d -print0 \
| xargs -0 -I {} -P "$max_jobs" bash -c 'bundle_repo "$@"' _ {}
log_verbose "Parallel bundling completed, waiting for monitor"
# Kill the monitor process and wait for it to finish
kill "$monitor_pid" 2>/dev/null || true
wait "$monitor_pid" 2>/dev/null || true
# Print final progress
final_count=$(cat "$progress_file" 2>/dev/null || echo "0")
printf "\rBundling repositories: %d/%d" "$final_count" "$total_count"
printf "\n"
log_verbose "Final count: $final_count"
# Read failed repositories
failed_repos=()
if [ -s "$failed_file" ]; then
while IFS= read -r failed_repo; do
failed_repos+=("$failed_repo")
done < "$failed_file"
fi
# Print all failed repositories
if [ ${#failed_repos[@]} -gt 0 ]; then
for failed_repo in "${failed_repos[@]}"; do
printf "Failed to bundle '%s'\n" "$failed_repo"
done
printf "\n"
fi
# Calculate final counts
failed_count=${#failed_repos[@]}
success_count=$((total_count - failed_count))
printf "Summary:\n"
printf " %-22s %d\n" "Total repositories:" "$total_count"
printf " %-22s %d\n" "Successfully bundled:" "$success_count"
printf " %-22s %d\n" "Failed:" "$failed_count"
printf " %-22s %d\n" "Parallel jobs used:" "$max_jobs"
printf "\n"
cat <<- 'EOF'
Finished!
To extract the bundles, use the following command:
git clone <bundle-file> <destination-directory>
EOF
# open the output directory
if command -v xdg-open >/dev/null 2>&1; then
xdg-open "$output_dir" > /dev/null 2>&1
elif command -v open >/dev/null 2>&1; then
open "$output_dir" > /dev/null 2>&1
else
echo "Please open the output directory manually: $output_dir"
fi
}
# Restore function - clones bundles from directory
restore() {
local bundle_dir="${1:-$output_dir}"
local dest_dir="${2:-$repo_dir}"
echo "Starting git bundle restoration!"
log_verbose "Bundle directory: $bundle_dir"
log_verbose "Destination directory: $dest_dir"
# Validate bundle directory exists
if [ ! -d "$bundle_dir" ]; then
echo "Error: Bundle directory '$bundle_dir' does not exist"
return 1
fi
# Create destination directory if it doesn't exist
mkdir -p "$dest_dir"
# Check for existing repositories that would be overwritten
local existing_repos=()
if ls "$bundle_dir"/*.bundle >/dev/null 2>&1; then
for bundle_file in "$bundle_dir"/*.bundle; do
# Skip if no bundles found (glob didn't match)
[ -f "$bundle_file" ] || continue
local bundle_name
bundle_name=$(basename "$bundle_file" .bundle)
local dest_path="$dest_dir/$bundle_name"
if [ -d "$dest_path" ]; then
existing_repos+=("$bundle_name")
fi
done
fi
# If there are existing repositories, ask for confirmation (unless --force is used)
if [ ${#existing_repos[@]} -gt 0 ]; then
echo "Warning: The following repositories already exist and will be overwritten:"
for repo in "${existing_repos[@]}"; do
echo " - $repo"
done
echo ""
if [ "$force" = false ]; then
read -p "Continue and overwrite existing repositories? (y/N): " -r
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Restore cancelled by user"
return 0
fi
echo ""
else
log_verbose "Force flag enabled, proceeding without confirmation"
fi
fi
# Count total bundles
local bundle_count=0
local success_count=0
local failed_count=0
# Find all bundle files (using more robust method for macOS)
if ls "$bundle_dir"/*.bundle >/dev/null 2>&1; then
for bundle_file in "$bundle_dir"/*.bundle; do
# Skip if no bundles found (glob didn't match)
[ -f "$bundle_file" ] || continue
bundle_count=$((bundle_count + 1))
local bundle_name
bundle_name=$(basename "$bundle_file" .bundle)
local dest_path="$dest_dir/$bundle_name"
printf "Restoring %s..." "$bundle_name"
# Remove existing directory if it exists
if [ -d "$dest_path" ]; then
log_verbose "Removing existing directory: $dest_path"
rm -rf "$dest_path"
fi
if git clone "$bundle_file" "$dest_path" >/dev/null 2>&1; then
printf " ✓\n"
success_count=$((success_count + 1))
else
printf " ✗\n"
failed_count=$((failed_count + 1))
log_verbose "Failed to clone $bundle_file to $dest_path"
fi
done
fi
printf "\nRestore Summary:\n"
printf " %-22s %d\n" "Total bundles:" "$bundle_count"
printf " %-22s %d\n" "Successfully restored:" "$success_count"
printf " %-22s %d\n" "Failed:" "$failed_count"
printf "\n"
if [ "$bundle_count" -eq 0 ]; then
echo "No bundle files found in $bundle_dir"
else
echo "Finished restoring bundles to $dest_dir"
fi
}
# Parse flags after command
parse_flags() {
while [[ $# -gt 0 ]]; do
case $1 in
-v|--verbose)
verbose=true
shift
;;
-x|--debug)
set -x
shift
;;
-f|--force)
force=true
shift
;;
-*)
echo "Unknown option: $1"
echo "Use 'git_bundle.sh help' for usage information."
exit 1
;;
*)
# Return remaining args
echo "$@"
return
;;
esac
done
}
# Main execution
main() {
# Initialize globals and setup cleanup
init_globals
trap cleanup EXIT
# Parse command line arguments
case "${1:-backup}" in
backup|b)
shift
parse_flags "$@" >/dev/null
backup
;;
restore|r)
shift
remaining_args=$(parse_flags "$@")
eval "set -- $remaining_args"
restore "$1" "$2"
;;
help|h|-h|--help)
cat <<- 'EOF'
Usage: git_bundle.sh [COMMAND] [OPTIONS]
Commands:
backup, b Create bundles of all repositories (default)
restore, r [src] [dest] Restore bundles from source to destination
help, h Show this help message
Options:
-v, --verbose Enable verbose/debug output
-x, --debug Enable bash debug tracing
-f, --force Force overwrite without confirmation (restore only)
Environment Variables:
REPO_DIR Source directory for repositories (default: ~/git)
OUTPUT_DIR Output directory for bundles (default: /tmp)
MAX_JOBS Maximum parallel jobs (default: auto-detect)
Examples:
git_bundle.sh # Create bundles (default behavior)
git_bundle.sh backup # Create bundles explicitly
git_bundle.sh backup -v # Verbose backup
git_bundle.sh restore # Restore from /tmp to ~/git
git_bundle.sh restore -f # Force restore without confirmation
git_bundle.sh restore /path/bundles /path/repos # Custom paths
git_bundle.sh restore -v /path/bundles /path/repos # Verbose restore
EOF
;;
*)
echo "Unknown command: $1"
echo "Use 'git_bundle.sh help' for usage information."
exit 1
;;
esac
}
main "$@"
@pythoninthegrass
Copy link
Author

pythoninthegrass commented Jun 14, 2025

git clone [email protected]:3b1119c05780110313bd8ca97af1defb.git git_bundle && cd $_

# defaults
./git_bundle.sh

# override defaults
REPO_DIR=~/Documents/src OUTPUT_DIR=~/Desktop/git ./git_bundle.sh

# set max jobs
MAX_JOBS=$(nproc 2>/dev/null || sysctl -n hw.ncpu) ./git_bundle.sh

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