#!/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' echo "NAME" echo " $SCRIPT_NAME - back up git changes to a timestamped remote tag" echo "SYNOPSIS" echo " $SCRIPT_NAME [--dry-run] ${s}repo_directory${r} [${s}remote${r}]" echo " $SCRIPT_NAME -h" echo "DESCRIPTION" echo " Stashes all tracked and untracked changes, detaches HEAD, commits" echo " everything, tags it backup-YYYY-MM-DD-HHMM.SS (UTC), pushes the tag" echo " to the remote, then restores the original branch and working state." echo " The local tag is deleted after pushing; the remote tag persists." echo "" echo " Pushes immediately without confirmation -- the backup tag is named" echo " uniquely by timestamp and won't overwrite or interfere with your main" echo " work. Use --dry-run first if you want to see what would happen." echo "" echo " If \`git stash apply\` hits conflicts, the script auto-resolves them by" echo " keeping the stashed version (\`git checkout --theirs\`). See CAVEATS." echo "OPTIONS" echo " -n, --dry-run Print the commands that would run, make no changes" echo " ${s}repo_directory${r} Path to the git repository (e.g. . for CWD)" echo " ${s}remote${r} Remote name (default: origin)" echo " -h, --help Show this help message" echo "PRECONDITIONS" echo " - ${s}repo_directory${r} is an accessible git working tree" echo " - ${s}remote${r} is configured in that repo and you have push access" echo "EXIT STATUS" echo " 0 Success" echo " 1 Runtime failure (git command failed)" echo " 2 Usage / precondition error (remote missing, bad args)" echo "DEPENDENCIES" echo " git" echo "CAVEATS" echo " Conflict auto-resolution uses \`git checkout --theirs\` (keep stashed" echo " side). This is destructive for the 'ours' side of any conflict --" echo " review the backup tag after a conflict-resolved run." echo "" echo " Backup tags persist on the remote and are not auto-pruned. Clean" echo " them up periodically with:" echo " git push ${s}remote${r} :refs/tags/backup-YYYY-..." } _error() { echo "[ERR][$SCRIPT_NAME] $*" >&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 } # Clean up inner functions on return # 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 option: $1. Run \`$SCRIPT_NAME -h\` for usage" return 2 ;; *) positional+=("$1"); shift ;; esac done if [ "${#positional[@]}" -eq 0 ]; then _error "No directory provided. 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"