Last active
June 14, 2025 03:33
-
-
Save pythoninthegrass/3b1119c05780110313bd8ca97af1defb to your computer and use it in GitHub Desktop.
Alternative to git archive that retains git tracked files/directories
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
#!/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 "$@" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.