#!/bin/bash # git-backup - Stash work in progress and push it as a timestamped backup tag # # Usage: # git-backup [--dry-run] [remote] # git-backup -h # # Options: # -n, --dry-run Print the commands that would run; make no changes # repo_directory Path to the git repository # remote Remote name (default: origin) # -h, --help Show help message _git_backup() ( local SCRIPT_NAME; SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")" case "${BASH_SOURCE[0]}" in /dev/*|/proc/*) SCRIPT_NAME="" ;; esac case "$SCRIPT_NAME" in ""|bash|sh|zsh|dash) SCRIPT_NAME="git-backup" ;; esac _show_help() { local s="" local r="" if { [ -t 1 ] || [ -n "${CLICOLOR_FORCE:-}" ]; } && [ -z "${NO_COLOR:-}" ]; then s=$'\033[4m' r=$'\033[24m' fi cat <&2; } _dry() { printf '[DRY][%s] %s\n' "$SCRIPT_NAME" "$*"; } _failed() { _error "\`$1\` failed"; } _shell_join() { local out="" local arg local quoted for arg in "$@"; do printf -v quoted '%q' "$arg" if [ -n "$out" ]; then out="$out "; fi out="$out$quoted" done printf '%s' "$out" } _expand_short_opts() { # $1 = string of short-opt letters that take a value (e.g. "nXHd"); "" for flag-only scripts # $2..$N = "$@" # Populates _EXPANDED; caller does: set -- "${_EXPANDED[@]}"; unset _EXPANDED local value_opts="$1"; shift _EXPANDED=() local passthru="" local arg local rest local c for arg in "$@"; do if [ -n "$passthru" ]; then _EXPANDED+=("$arg"); continue; fi case "$arg" in --) passthru=1; _EXPANDED+=("$arg") ;; --*|-|"") _EXPANDED+=("$arg") ;; -[a-zA-Z]?*) rest="${arg#-}" while [ -n "$rest" ]; do c="${rest%"${rest#?}"}"; rest="${rest#?}" _EXPANDED+=("-$c") case "$value_opts" in *"$c"*) [ -n "$rest" ] && _EXPANDED+=("$rest") rest="" ;; esac done ;; *) _EXPANDED+=("$arg") ;; esac done } # Parse args: collect flags, then positional local dry_run=0 local positional=() _expand_short_opts "" "$@" set -- "${_EXPANDED[@]}"; unset _EXPANDED while [ $# -gt 0 ]; do case "$1" in -h|--help) _show_help; return 0 ;; -n|--dry-run) dry_run=1; shift ;; --) shift; while [ $# -gt 0 ]; do positional+=("$1"); shift; done ;; -*) _error "Unknown argument '$1'. Run \`$SCRIPT_NAME -h\` for usage" return 2 ;; *) positional+=("$1"); shift ;; esac done if [ "${#positional[@]}" -eq 0 ]; then _error "Must provide a directory. Run \`$SCRIPT_NAME -h\` for usage" return 2 fi # Directory of the Git repository local repo_dir="${positional[0]}" # Name of remote to push to - git defaults to "origin" so we do too local remote="${positional[1]:-"origin"}" local timestamp; timestamp="$(TZ=Etc/UTC date +"%Y-%m-%d-%H%M.%S")" local backup_name="backup-$timestamp" if [ "$dry_run" -eq 1 ]; then _dry "Would back up: '$repo_dir' -> remote '$remote' as tag '$backup_name'" _dry "Would run: $(_shell_join cd "$repo_dir")" _dry "Would run: $(_shell_join git remote get-url "$remote")" _dry "Would run: $(_shell_join git stash save --include-untracked "$backup_name")" _dry "Would run: $(_shell_join git checkout --detach)" _dry "Would run: $(_shell_join git stash apply)" _dry "Would run: $(_shell_join git add .)" _dry "Would run: $(_shell_join git commit -m "Backup $timestamp")" _dry "Would run: $(_shell_join git tag "$backup_name")" _dry "Would run: $(_shell_join git push "$remote" "refs/tags/$backup_name")" _dry "Would run: $(_shell_join git checkout -)" _dry "Would run: $(_shell_join git stash apply)" _dry "Would run: $(_shell_join git tag -d "$backup_name")" return 0 fi echo "Starting git repo backup $timestamp" # Change to the repository directory if ! cd "$repo_dir"; then _error "Failed to change to directory \"$repo_dir\"" return 1 fi # Pre-flight: the named remote must exist. Without this, `git push` later # produces a raw git error that doesn't say which side (tooling or config) # is at fault if ! git remote get-url "$remote" >/dev/null 2>&1; then _error "Remote '$remote' not configured in '$repo_dir'. Run \`$SCRIPT_NAME -h\` for usage" return 2 fi # Stash any uncommitted changes and untracked files if ! git stash save --include-untracked "$backup_name"; then _failed "git stash save --include-untracked \"$backup_name\"" return 1 fi # Detach HEAD so the backup commit lands on no branch if ! git checkout --detach; then _failed "git checkout --detach" return 1 fi # Apply the changes from stash, overwriting any conflicts local stash_apply_output if ! stash_apply_output="$(git stash apply 2>&1)"; then _failed "git stash apply" return 1 fi if echo "$stash_apply_output" | tee /dev/stdout | grep -q 'CONFLICT'; then echo "Conflicts detected during 'git stash apply'. Attempting to resolve conflicts by keeping stashed changes." git status --short | grep '^U' | cut -c 4- \ | while read -r file; do echo "Resolving conflict in $file" if ! git checkout --theirs "$file"; then _failed "git checkout --theirs \"$file\"" return 1 fi git add "$file" done echo "Conflicts resolved. Committing the changes." fi # Add all changes to the index if ! git add .; then _failed "git add ." return 1 fi # Commit the changes if ! git commit -m "Backup $timestamp"; then _failed "git commit -m \"Backup $timestamp\"" return 1 fi # Tag the commit; the tag is what gets pushed and persists on the remote if ! git tag "$backup_name"; then _failed "git tag \"$backup_name\"" return 1 fi # Push the tag to the remote repository if ! git push "$remote" "refs/tags/$backup_name"; then _failed "git push \"$remote\" \"refs/tags/$backup_name\"" return 1 fi # Switch back to the original branch if ! git checkout -; then _failed "git checkout -" return 1 fi # Restore changes to original branch if ! git stash apply; then _failed "git stash apply" return 1 fi # Delete the local tag, now that it has been pushed to the remote if ! git tag -d "$backup_name"; then _failed "git tag -d \"$backup_name\"" return 1 fi ) _git_backup "$@" __git_backup_rc=$? unset -f _git_backup if [ -n "${BASH_SOURCE[0]}" ] && [ "${BASH_SOURCE[0]}" != "$0" ]; then eval "unset __git_backup_rc; return $__git_backup_rc" fi eval "unset __git_backup_rc; exit $__git_backup_rc"