Sitemap

A bash script to archive new and uncommitted files in a local git repository

4 min readDec 14, 2024

--

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

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