A bash script to safely archive all uncommitted files in a local git repository

Terris Linenbach
4 min readDec 14, 2024

--

A cute fox next to a safe
A gratuitous image that wasted my time and erased future generations via CO2 emissions

Want to back up your work without pushing it to the remote repository due to the precommit hooks?

Run this script within a local git repository. All modified and new files in the repository are zipped to ~/.git-backups/<repo-name>-<branch-name>/<repo-name>-<branch-name>-<timestamp>.zip

Warning: This script ignores files that have been committed regardless of whether they’ve been pushed to the remote repository. If it’s not output by git status , it won’t be included in the archive.

This script runs fine on MacOS (which always requires extra work!) and it *should* run on Linux but I haven’t tried it.

Preparation

  • MacOS: brew install zip
  • Copy the script below to (for example) ~/gitzip
  • chmod +x ~/gitzip
#!/bin/bash

# Initialize debug flag (default: disabled)
# Enable via: --debug
debug=0

# Process command line arguments
while [[ "$#" -gt 0 ]]; do
case $1 in
--debug) debug=1; set -x ;;
*) error "Unknown parameter: $1" ;;
esac
shift
done

# Debug output function
debug_echo() {
if [ "$debug" -eq 1 ]; then
echo "DEBUG: $1"
fi
}

# Run this script within a local git repository.
# All modified and new files in the repository are zipped to
# ~/.git-backups/<repository>-<branch>/<repository>-<branch>-<timestamp>.zip

# Function to print error messages to stderr and exit
error() {
echo "Error: $1" >&2
exit 1
}

# Function to generate a timestamp in the format YYYYMMDD-HHMMSS
generate_timestamp() {
date +"%Y%m%d-%H%M%S"
}

# Function to sanitize strings for use in file and directory names
sanitize() {
echo "$1" | tr ' /\\:*?"<>|' '----------'
}

# Change to the root of the Git repository
git_root=$(git rev-parse --show-toplevel 2>/dev/null) || error "Not inside a Git repository."
cd "$git_root" || error "Failed to change directory to Git root."

# Retrieve the repository name using the basename of the Git root directory
repo_name=$(basename "$git_root")
repo_name_sanitized=$(sanitize "$repo_name")

# Get the current Git branch name, fallback to commit hash if in detached HEAD
branch_name=$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --short HEAD 2>/dev/null || echo "no-branch") || error "Failed to identify current Git state."
branch_name_sanitized=$(sanitize "$branch_name")

# Get the list of changed files (staged and unstaged)
if git rev-parse --verify HEAD >/dev/null 2>&1; then
changed_files=$(git diff --name-only HEAD | grep -v "^\.specstory/")
else
changed_files=""
fi

debug_echo "Changed files found:"
debug_echo "$changed_files"

# Get the list of untracked files
untracked_files=$(git ls-files --others --exclude-standard | grep -v "^\.specstory/")
debug_echo "Untracked files found:"
debug_echo "$untracked_files"

# Exit on error - do after the above because grep returns error when there are no matches
set -e

# Before creating temp file
debug_echo "About to create temporary file"
if ! command -v mktemp >/dev/null 2>&1; then
error "mktemp command not found"
fi

# Create temporary file with error handling
if ! file_list=$(mktemp); then
error "Failed to create temporary file"
fi
debug_echo "Created temporary file: $file_list"

# Verify the file exists and is writable
if [ ! -f "$file_list" ]; then
error "Temporary file was not created"
fi
if [ ! -w "$file_list" ]; then
error "Temporary file is not writable"
fi

# Add trap right after successful creation
trap 'rm -f "$file_list" 2>/dev/null; rm -rf "$temp_dir" 2>/dev/null' EXIT
debug_echo "Trap set for cleanup"

# Store changed files
if [ -n "$changed_files" ]; then
debug_echo "Writing changed files to temporary file"
printf '%s\n' "$changed_files" > "$file_list" || error "Failed to write changed files"
fi

# Append untracked files
if [ -n "$untracked_files" ]; then
debug_echo "Writing untracked files to temporary file"
printf '%s\n' "$untracked_files" >> "$file_list" || error "Failed to write untracked files"
fi

debug_echo "Reading combined files"
combined_files=$(sort -u "$file_list") || error "Failed to combine files"

debug_echo "Files to be processed:"
debug_echo "$combined_files"

# Check if there are any files to back up
if [ -z "$combined_files" ]; then
echo "No changed or untracked files found."
exit 0
fi

# Create a temporary directory for copying changed and untracked files
temp_dir=$(mktemp -d) || error "Failed to create temporary directory."
debug_echo "Created temporary directory: $temp_dir"
# Ensure the temporary directory is removed on script exit
trap 'rm -f "$file_list"; rm -rf "$temp_dir"' EXIT

# Copy changed and untracked files to the temporary directory, preserving directory structure
while IFS= read -r file; do
debug_echo "Processing file: $file"
# Ensure the file exists (it might have been deleted)
if [ -e "$file" ]; then
# Create the target directory inside temp_dir
target_dir="$temp_dir/$(dirname "$file")"
mkdir -p "$target_dir" || error "Failed to create directory structure for $file."

# Copy the file to the target directory
cp "$file" "$target_dir/" || error "Failed to copy file: $file"
else
debug_echo "Skipping deleted file: $file"
fi

done <<< "$combined_files"

# Create the zip file with the repository and branch name
zip_file="${repo_name_sanitized}-${branch_name_sanitized}-$(generate_timestamp).zip"
(
cd "$temp_dir" || error "Failed to change directory to temporary directory."
zip -r "$zip_file" ./* >/dev/null || error "Failed to create zip archive."
)

# Define the backup directory path
backup_dir="$HOME/.git-backups/${repo_name_sanitized}-${branch_name_sanitized}"

# Create the backup directory
mkdir -p "$backup_dir" || error "Failed to create backup directory: $backup_dir"

# Move the zip file to the backup directory
mv "$temp_dir/$zip_file" "$backup_dir/" || error "Failed to move zip file."

# Inform the user of the backup location
unzip -v "$backup_dir/$zip_file"

--

--

Terris Linenbach
Terris Linenbach

Written by Terris Linenbach

He/him. Coder since 1980. Always seeking the Best Way. CV: https://terris.com/cv

No responses yet