Skip to content

Instantly share code, notes, and snippets.

@dzogrim
Created August 14, 2026 12:16
Show Gist options
  • Select an option

  • Save dzogrim/3248a9c6f2d2693ab0117c7843c06832 to your computer and use it in GitHub Desktop.

Select an option

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.
#!/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