Created
August 14, 2026 12:16
-
-
Save dzogrim/3248a9c6f2d2693ab0117c7843c06832 to your computer and use it in GitHub Desktop.
Safely collapse an entire git history into a single commit using an orphan branch, then force-push the result to the remote.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # | |
| # git-reset-history.sh | |
| # | |
| # Safely collapse an entire git history into a single commit using an | |
| # orphan branch, then (optionally) force-push the result to the remote. | |
| # | |
| # Design goals: | |
| # * Works on macOS (stock bash 3.2) and Linux. No GNU-only flags. | |
| # * Does not rely on the user's git configuration (pager, color, aliases). | |
| # * Refuses to run until every precondition is verified. | |
| # * Creates a verified backup bundle before touching anything. | |
| # * Requires explicit, typed confirmations for destructive steps. | |
| # * Attempts automatic rollback if anything fails mid-way. | |
| # | |
| # Usage: | |
| # ./git-reset-history.sh [options] | |
| # | |
| # Options: | |
| # -b, --branch <name> Branch to reset (default: auto-detected current branch) | |
| # -r, --remote <name> Remote to push to (default: origin) | |
| # -m, --message <msg> Commit message for the new single commit | |
| # (default: "Initial commit") | |
| # --no-push Rewrite locally only; skip the force push | |
| # --dry-run Show what would happen, change nothing | |
| # -h, --help Show this help | |
| # | |
| set -euo pipefail | |
| # --------------------------------------------------------------------------- | |
| # Constants and defaults | |
| # --------------------------------------------------------------------------- | |
| SCRIPT_NAME="$(basename "$0")" | |
| REMOTE="origin" | |
| BRANCH="" | |
| COMMIT_MSG="Initial commit" | |
| DO_PUSH=1 | |
| DRY_RUN=0 | |
| TIMESTAMP="$(date +%Y%m%d-%H%M%S)" | |
| TEMP_BRANCH="tmp-history-reset-$$-${TIMESTAMP}" | |
| BACKUP_BUNDLE="" | |
| ORIG_BRANCH="" | |
| ORIG_HEAD_SHA="" | |
| STATE="init" # init -> on_temp_branch -> committed -> branch_replaced -> done | |
| # --------------------------------------------------------------------------- | |
| # Output helpers (colors only when stdout is a terminal) | |
| # --------------------------------------------------------------------------- | |
| if [ -t 1 ]; then | |
| C_RED="$(printf '\033[31m')"; C_YEL="$(printf '\033[33m')" | |
| C_GRN="$(printf '\033[32m')"; C_BLD="$(printf '\033[1m')" | |
| C_RST="$(printf '\033[0m')" | |
| else | |
| C_RED=""; C_YEL=""; C_GRN=""; C_BLD=""; C_RST="" | |
| fi | |
| info() { printf '%s\n' "${C_GRN}[INFO]${C_RST} $*"; } | |
| warn() { printf '%s\n' "${C_YEL}[WARN]${C_RST} $*"; } | |
| error() { printf '%s\n' "${C_RED}[ERROR]${C_RST} $*" >&2; } | |
| die() { error "$*"; exit 1; } | |
| # Run git with a neutral environment so user config cannot interfere | |
| # with parsing (pager, colors, terminal prompts). | |
| g() { | |
| GIT_TERMINAL_PROMPT=0 git -c core.pager=cat -c color.ui=false "$@" | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Usage / argument parsing | |
| # --------------------------------------------------------------------------- | |
| usage() { | |
| sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//' | |
| exit "${1:-0}" | |
| } | |
| while [ $# -gt 0 ]; do | |
| case "$1" in | |
| -b|--branch) [ $# -ge 2 ] || die "Missing value for $1"; BRANCH="$2"; shift 2 ;; | |
| -r|--remote) [ $# -ge 2 ] || die "Missing value for $1"; REMOTE="$2"; shift 2 ;; | |
| -m|--message) [ $# -ge 2 ] || die "Missing value for $1"; COMMIT_MSG="$2"; shift 2 ;; | |
| --no-push) DO_PUSH=0; shift ;; | |
| --dry-run) DRY_RUN=1; shift ;; | |
| -h|--help) usage 0 ;; | |
| *) error "Unknown option: $1"; usage 1 ;; | |
| esac | |
| done | |
| # --------------------------------------------------------------------------- | |
| # Interactive confirmation helpers | |
| # --------------------------------------------------------------------------- | |
| require_tty() { | |
| if [ ! -t 0 ]; then | |
| die "This script requires an interactive terminal for confirmations. Refusing to run non-interactively." | |
| fi | |
| } | |
| ask_yes_no() { | |
| # ask_yes_no "question" -> returns 0 on yes, 1 on no | |
| local answer | |
| while true; do | |
| printf '%s [y/N]: ' "$1" | |
| read -r answer || die "Input closed. Aborting." | |
| case "$answer" in | |
| y|Y|yes|YES) return 0 ;; | |
| n|N|no|NO|"") return 1 ;; | |
| *) printf 'Please answer y or n.\n' ;; | |
| esac | |
| done | |
| } | |
| ask_exact() { | |
| # ask_exact "prompt" "expected" -> dies unless user types the exact string | |
| local answer | |
| printf '%s\n' "$1" | |
| printf '> ' | |
| read -r answer || die "Input closed. Aborting." | |
| if [ "$answer" != "$2" ]; then | |
| die "Confirmation did not match ('$answer' != '$2'). Aborting. Nothing was changed." | |
| fi | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Rollback on failure | |
| # --------------------------------------------------------------------------- | |
| rollback() { | |
| local exit_code=$? | |
| trap - EXIT | |
| if [ "$exit_code" -eq 0 ] || [ "$STATE" = "done" ] || [ "$DRY_RUN" -eq 1 ]; then | |
| exit "$exit_code" | |
| fi | |
| printf '\n' | |
| error "Something failed (state: $STATE). Attempting rollback..." | |
| case "$STATE" in | |
| on_temp_branch|committed) | |
| # Original branch still exists; go back to it and drop the temp branch. | |
| if g checkout -f "$ORIG_BRANCH" >/dev/null 2>&1; then | |
| g branch -D "$TEMP_BRANCH" >/dev/null 2>&1 || true | |
| info "Rolled back to branch '$ORIG_BRANCH'. Repository is unchanged." | |
| else | |
| error "Automatic rollback failed. Manual recovery:" | |
| printf ' git checkout -f %s\n' "$ORIG_BRANCH" | |
| printf ' git branch -D %s\n' "$TEMP_BRANCH" | |
| fi | |
| ;; | |
| branch_replaced) | |
| error "The local branch was already replaced. Recover the old history with:" | |
| printf ' git branch %s-restored %s\n' "$ORIG_BRANCH" "$ORIG_HEAD_SHA" | |
| printf ' or restore everything from the backup bundle:\n' | |
| printf ' git clone "%s" restored-repo\n' "$BACKUP_BUNDLE" | |
| ;; | |
| *) | |
| info "No repository changes had been made yet. Nothing to roll back." | |
| ;; | |
| esac | |
| [ -n "$BACKUP_BUNDLE" ] && info "Backup bundle is preserved at: $BACKUP_BUNDLE" | |
| exit "$exit_code" | |
| } | |
| trap rollback EXIT | |
| # --------------------------------------------------------------------------- | |
| # Preflight checks | |
| # --------------------------------------------------------------------------- | |
| info "Running preflight checks..." | |
| command -v git >/dev/null 2>&1 || die "git is not installed or not on PATH." | |
| g rev-parse --is-inside-work-tree >/dev/null 2>&1 \ | |
| || die "Not inside a git working tree. cd into your repository first." | |
| [ "$(g rev-parse --is-bare-repository)" = "false" ] \ | |
| || die "This is a bare repository. This script must run in a working tree." | |
| if [ -f "$(g rev-parse --git-dir)/MERGE_HEAD" ]; then | |
| die "A merge is in progress. Finish or abort it first." | |
| fi | |
| if [ -d "$(g rev-parse --git-dir)/rebase-merge" ] || [ -d "$(g rev-parse --git-dir)/rebase-apply" ]; then | |
| die "A rebase is in progress. Finish or abort it first." | |
| fi | |
| # Identity must exist or the commit step will fail (we never set it for the user). | |
| g config user.name >/dev/null 2>&1 || die "git user.name is not set. Set it first: git config user.name \"Your Name\"" | |
| g config user.email >/dev/null 2>&1 || die "git user.email is not set. Set it first: git config user.email \"you@example.com\"" | |
| # Detect current branch; refuse detached HEAD. | |
| ORIG_BRANCH="$(g symbolic-ref --short -q HEAD || true)" | |
| [ -n "$ORIG_BRANCH" ] || die "HEAD is detached. Check out a branch first." | |
| if [ -z "$BRANCH" ]; then | |
| BRANCH="$ORIG_BRANCH" | |
| fi | |
| if [ "$BRANCH" != "$ORIG_BRANCH" ]; then | |
| die "You asked to reset '$BRANCH' but you are currently on '$ORIG_BRANCH'. Check out '$BRANCH' first so the working tree matches what will be committed." | |
| fi | |
| ORIG_HEAD_SHA="$(g rev-parse HEAD)" | |
| REPO_ROOT="$(g rev-parse --show-toplevel)" | |
| REPO_NAME="$(basename "$REPO_ROOT")" | |
| COMMIT_COUNT="$(g rev-list --count HEAD)" | |
| TAG_COUNT="$(g tag | wc -l | tr -d '[:space:]')" | |
| OTHER_BRANCHES="$(g for-each-ref --format='%(refname:short)' refs/heads | grep -v -x "$BRANCH" || true)" | |
| # Remote checks (only if we intend to push). | |
| REMOTE_URL="" | |
| if [ "$DO_PUSH" -eq 1 ]; then | |
| REMOTE_URL="$(g remote get-url "$REMOTE" 2>/dev/null)" \ | |
| || die "Remote '$REMOTE' does not exist. Use --remote <name> or --no-push." | |
| fi | |
| # Working tree state. | |
| DIRTY=0 | |
| if ! g diff --quiet || ! g diff --cached --quiet; then | |
| DIRTY=1 | |
| fi | |
| UNTRACKED="$(g ls-files --others --exclude-standard | head -n 5)" | |
| UNTRACKED_COUNT="$(g ls-files --others --exclude-standard | wc -l | tr -d '[:space:]')" | |
| SUBMODULES=0 | |
| if [ -f "$REPO_ROOT/.gitmodules" ]; then | |
| SUBMODULES=1 | |
| fi | |
| # --------------------------------------------------------------------------- | |
| # Summary and warnings | |
| # --------------------------------------------------------------------------- | |
| printf '\n' | |
| printf '%s\n' "${C_BLD}============================================================${C_RST}" | |
| printf '%s\n' "${C_BLD} GIT HISTORY RESET (irreversible after push)${C_RST}" | |
| printf '%s\n' "${C_BLD}============================================================${C_RST}" | |
| printf ' Repository: %s\n' "$REPO_ROOT" | |
| printf ' Branch to reset: %s\n' "$BRANCH" | |
| printf ' Current HEAD: %s\n' "$ORIG_HEAD_SHA" | |
| printf ' Commits to drop: %s (history will become 1 commit)\n' "$COMMIT_COUNT" | |
| printf ' New message: %s\n' "$COMMIT_MSG" | |
| if [ "$DO_PUSH" -eq 1 ]; then | |
| printf ' Force push to: %s (%s)\n' "$REMOTE" "$REMOTE_URL" | |
| else | |
| printf ' Force push: SKIPPED (--no-push)\n' | |
| fi | |
| printf '%s\n' "${C_BLD}============================================================${C_RST}" | |
| printf '\n' | |
| warn "This PERMANENTLY rewrites history. Old commits become unreachable" | |
| warn "and will eventually be garbage-collected. Anyone else who has" | |
| warn "cloned this repository will have a broken/diverged copy." | |
| if [ "$DIRTY" -eq 1 ]; then | |
| warn "You have UNCOMMITTED CHANGES. They will be INCLUDED in the new single commit." | |
| fi | |
| if [ "$UNTRACKED_COUNT" -gt 0 ]; then | |
| warn "You have $UNTRACKED_COUNT UNTRACKED file(s) (respecting .gitignore). They will be ADDED and committed. First few:" | |
| printf '%s\n' "$UNTRACKED" | sed 's/^/ - /' | |
| fi | |
| if [ "$TAG_COUNT" -gt 0 ]; then | |
| warn "This repo has $TAG_COUNT tag(s). Tags still point at the OLD history and are NOT rewritten or deleted by this script." | |
| fi | |
| if [ -n "$OTHER_BRANCHES" ]; then | |
| warn "Other local branches exist and are NOT touched (they still hold old history):" | |
| printf '%s\n' "$OTHER_BRANCHES" | sed 's/^/ - /' | |
| fi | |
| if [ "$SUBMODULES" -eq 1 ]; then | |
| warn "This repo uses SUBMODULES. Verify .gitmodules is committed correctly afterwards." | |
| fi | |
| if [ "$DO_PUSH" -eq 1 ]; then | |
| warn "If the remote branch is PROTECTED (GitHub branch protection), the force push will be rejected until protection is relaxed." | |
| warn "Open pull requests targeting '$BRANCH' will likely be closed or corrupted by the rewrite." | |
| fi | |
| printf '\n' | |
| if [ "$DRY_RUN" -eq 1 ]; then | |
| info "DRY RUN: no changes made. The script would perform:" | |
| printf ' 1. git bundle create <backup> --all (verified backup)\n' | |
| printf ' 2. git checkout --orphan %s\n' "$TEMP_BRANCH" | |
| printf ' 3. git add -A && git commit -m "%s"\n' "$COMMIT_MSG" | |
| printf ' 4. git branch -D %s && git branch -m %s\n' "$BRANCH" "$BRANCH" | |
| if [ "$DO_PUSH" -eq 1 ]; then | |
| printf ' 5. git push --force-with-lease %s %s\n' "$REMOTE" "$BRANCH" | |
| fi | |
| STATE="done" | |
| exit 0 | |
| fi | |
| require_tty | |
| # --------------------------------------------------------------------------- | |
| # Confirmations | |
| # --------------------------------------------------------------------------- | |
| ask_exact "To confirm the target, type the repository directory name exactly: ${C_BLD}${REPO_NAME}${C_RST}" "$REPO_NAME" | |
| if [ "$DIRTY" -eq 1 ] || [ "$UNTRACKED_COUNT" -gt 0 ]; then | |
| ask_yes_no "Include uncommitted/untracked changes in the new single commit?" \ | |
| || die "Aborted. Commit, stash, or clean your working tree, then re-run." | |
| fi | |
| ask_exact "Final safety check. Type exactly: ${C_BLD}REWRITE HISTORY${C_RST}" "REWRITE HISTORY" | |
| # --------------------------------------------------------------------------- | |
| # Step 0: verified backup bundle (all refs: branches and tags) | |
| # --------------------------------------------------------------------------- | |
| BACKUP_BUNDLE="${REPO_ROOT}/../${REPO_NAME}-backup-${TIMESTAMP}.bundle" | |
| info "Creating full backup bundle: $BACKUP_BUNDLE" | |
| g bundle create "$BACKUP_BUNDLE" --all | |
| g bundle verify "$BACKUP_BUNDLE" >/dev/null | |
| info "Backup verified. To restore later: git clone \"$BACKUP_BUNDLE\" restored-repo" | |
| printf ' Old HEAD SHA (write this down): %s\n\n' "$ORIG_HEAD_SHA" | |
| # --------------------------------------------------------------------------- | |
| # Step 1: orphan branch (blank history, same working tree) | |
| # --------------------------------------------------------------------------- | |
| info "Creating orphan branch '$TEMP_BRANCH'..." | |
| g checkout --orphan "$TEMP_BRANCH" | |
| STATE="on_temp_branch" | |
| # --------------------------------------------------------------------------- | |
| # Step 2: stage everything and create the single commit | |
| # --------------------------------------------------------------------------- | |
| info "Staging all files..." | |
| g add -A | |
| if g diff --cached --quiet; then | |
| die "Nothing to commit (repository appears empty). Aborting." | |
| fi | |
| info "Creating the new single commit..." | |
| g commit -m "$COMMIT_MSG" --quiet | |
| STATE="committed" | |
| NEW_SHA="$(g rev-parse HEAD)" | |
| info "New commit: $NEW_SHA" | |
| # --------------------------------------------------------------------------- | |
| # Step 3: replace the old branch | |
| # --------------------------------------------------------------------------- | |
| info "Replacing branch '$BRANCH' with the single-commit branch..." | |
| g branch -D "$BRANCH" >/dev/null | |
| STATE="branch_replaced" | |
| g branch -m "$BRANCH" | |
| info "Local branch '$BRANCH' now has exactly $(g rev-list --count HEAD) commit." | |
| # --------------------------------------------------------------------------- | |
| # Step 4: force push (optional, with its own confirmation) | |
| # --------------------------------------------------------------------------- | |
| if [ "$DO_PUSH" -eq 1 ]; then | |
| printf '\n' | |
| warn "About to FORCE PUSH to $REMOTE ($REMOTE_URL)." | |
| warn "This is the point of no return for the remote repository." | |
| if ask_yes_no "Force push '$BRANCH' to '$REMOTE' now?"; then | |
| info "Pushing (using --force-with-lease is not applicable to orphaned refs; using --force)..." | |
| g push --force "$REMOTE" "$BRANCH" | |
| info "Remote branch '$BRANCH' on '$REMOTE' now shows 1 commit." | |
| if [ "$TAG_COUNT" -gt 0 ]; then | |
| warn "Remote tags still reference the old history. Delete them manually if desired:" | |
| printf ' git push %s --delete <tagname> (and locally: git tag -d <tagname>)\n' "$REMOTE" | |
| fi | |
| else | |
| warn "Push skipped. Local history is rewritten; remote is UNCHANGED." | |
| printf 'To push later: git push --force %s %s\n' "$REMOTE" "$BRANCH" | |
| fi | |
| else | |
| info "--no-push: local rewrite complete; remote untouched." | |
| fi | |
| STATE="done" | |
| printf '\n' | |
| info "Done. Summary:" | |
| printf ' Old HEAD: %s (%s commits)\n' "$ORIG_HEAD_SHA" "$COMMIT_COUNT" | |
| printf ' New HEAD: %s (1 commit)\n' "$NEW_SHA" | |
| printf ' Backup: %s\n' "$BACKUP_BUNDLE" | |
| printf '\n' | |
| info "If you need the old history back:" | |
| printf ' git clone "%s" restored-repo\n' "$BACKUP_BUNDLE" | |
| printf ' or, before git gc runs:\n' | |
| printf ' git branch old-history %s\n' "$ORIG_HEAD_SHA" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment