#!/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; [ -t 1 ] && s=$'\033[4m' local r; [ -t 1 ] && r=$'\033[24m' cat <&2; } _failed() { _error "\`$1\` failed"; } _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 echo "[DRY RUN] would back up $repo_dir to $remote as tag $backup_name" echo "[DRY RUN] cd \"$repo_dir\"" echo "[DRY RUN] git remote get-url \"$remote\" # (pre-flight check)" echo "[DRY RUN] git stash save --include-untracked \"$backup_name\"" echo "[DRY RUN] git checkout --detach" echo "[DRY RUN] git stash apply # (auto-resolves conflicts with --theirs)" echo "[DRY RUN] git add ." echo "[DRY RUN] git commit -m \"Backup $timestamp\"" echo "[DRY RUN] git tag \"$backup_name\"" echo "[DRY RUN] git push \"$remote\" \"refs/tags/$backup_name\"" echo "[DRY RUN] git checkout -" echo "[DRY RUN] git stash apply" echo "[DRY RUN] 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"