#!/usr/bin/env bash set -euo pipefail # This script safely updates the current local branch on top of the official # Gitea upstream without losing local work. # It: # 1. checks that no git operation is already in progress; # 2. ensures the upstream remote exists and points to the official repository; # 3. creates a local backup branch at the current HEAD; # 4. creates a full exact snapshot of the current repository state; # 5. stashes tracked and untracked local changes; # 6. fetches the latest upstream changes and rebases the current branch on top # of upstream/main; # 7. reapplies the local stash only after a successful rebase. # It also offers a dry-run mode that performs the same verification flow in a # temporary clone, so the current repository is left untouched. # If a conflict happens, the backup branch and the stash are both kept so the # local work can be recovered manually. BRANCH="${BRANCH:-main}" REMOTE_NAME="${REMOTE_NAME:-upstream}" REMOTE_URL="${REMOTE_URL:-https://github.com/go-gitea/gitea.git}" REPO_ROOT="" GIT_DIR="" STATE_FILE="" DRY_RUN_ROOT="" SNAPSHOT_ROOT="" CURRENT_BRANCH="" BACKUP_BRANCH="" STASH_REF="" STASH_HASH="" STATE_BRANCH="" STATE_BACKUP_BRANCH="" STATE_STASH_HASH="" STATE_REMOTE_NAME="" STATE_REMOTE_URL="" STATE_TARGET_BRANCH="" STATE_START_HEAD="" STATE_SNAPSHOT_PATH="" STATE_STATUS="" STATE_UPDATED_AT="" say() { printf '%s\n' "$*" } die() { say "ERROR: $*" >&2 exit 1 } show_usage() { cat <"$STATE_FILE" } load_state() { ensure_git_repo [ -f "$STATE_FILE" ] || return 1 # shellcheck disable=SC1090 . "$STATE_FILE" return 0 } clear_state() { ensure_git_repo rm -f "$STATE_FILE" } worktree_has_local_changes() { ! git diff --quiet || ! git diff --cached --quiet || [ -n "$(git ls-files --others --exclude-standard)" ] } has_rebase_in_progress() { [ -e "$(git rev-parse --git-path rebase-merge)" ] || [ -e "$(git rev-parse --git-path rebase-apply)" ] } has_rebase_in_progress_in_repo() { local repo_path="$1" [ -d "$repo_path/.git/rebase-merge" ] || [ -d "$repo_path/.git/rebase-apply" ] } sanitize_ref_name() { printf '%s\n' "${1//\//-}" } ensure_rsync_available() { command -v rsync >/dev/null 2>&1 || die "rsync is required for exact repository snapshots." } snapshot_repo_dir() { printf '%s\n' "$1/repo" } is_valid_snapshot_dir() { [ -d "$1" ] && [ -d "$(snapshot_repo_dir "$1")" ] } create_worktree_snapshot_for_reason() { local reason="$1" safe_branch_name timestamp snapshot_dir snapshot_dir_repo ensure_rsync_available safe_branch_name="$(sanitize_ref_name "$CURRENT_BRANCH")" timestamp="$(date +%Y%m%d-%H%M%S)" snapshot_dir="$SNAPSHOT_ROOT/$safe_branch_name/${timestamp}-${reason}" snapshot_dir_repo="$(snapshot_repo_dir "$snapshot_dir")" mkdir -p "$snapshot_dir_repo" rsync --archive --delete --exclude='.git' "$REPO_ROOT"/ "$snapshot_dir_repo"/ { printf 'branch=%s\n' "$CURRENT_BRANCH" printf 'head=%s\n' "$(git rev-parse HEAD)" printf 'created_at=%s\n' "$(date +%Y-%m-%dT%H:%M:%S)" printf 'reason=%s\n' "$reason" } >"$snapshot_dir/metadata.txt" printf '%s\n' "$snapshot_dir" } restore_worktree_snapshot() { local snapshot_dir="$1" snapshot_dir_repo ensure_rsync_available is_valid_snapshot_dir "$snapshot_dir" || die "Snapshot directory '$snapshot_dir' is not valid." snapshot_dir_repo="$(snapshot_repo_dir "$snapshot_dir")" rsync --archive --delete --exclude='.git' "$snapshot_dir_repo"/ "$REPO_ROOT"/ } find_latest_snapshot_for_current() { local safe_branch_name snapshot_branch_dir safe_branch_name="$(sanitize_ref_name "$CURRENT_BRANCH")" snapshot_branch_dir="$SNAPSHOT_ROOT/$safe_branch_name" [ -d "$snapshot_branch_dir" ] || return 1 find "$snapshot_branch_dir" -mindepth 1 -maxdepth 1 -type d | sort -r | head -n 1 } ensure_no_nonrebase_git_operation_in_progress() { local git_path for git_path in MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD BISECT_LOG; do if [ -e "$(git rev-parse --git-path "$git_path")" ]; then die "A git operation is already in progress ($git_path). Finish it before running this script." fi done } ensure_no_git_operation_in_progress() { local git_path ensure_no_nonrebase_git_operation_in_progress for git_path in rebase-merge rebase-apply MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD BISECT_LOG; do if [ -e "$(git rev-parse --git-path "$git_path")" ]; then die "A git operation is already in progress ($git_path). Finish it before running this script." fi done if [ -n "$(git diff --name-only --diff-filter=U)" ]; then die "There are unmerged files in the working tree. Resolve them first." fi } ensure_current_branch() { CURRENT_BRANCH="$(git symbolic-ref --quiet --short HEAD || true)" [ -n "$CURRENT_BRANCH" ] || die "Detached HEAD is not supported. Check out a branch first." } ensure_current_branch_with_state_fallback() { CURRENT_BRANCH="$(git symbolic-ref --quiet --short HEAD || true)" if [ -n "$CURRENT_BRANCH" ]; then return fi if load_state && [ -n "${STATE_BRANCH:-}" ]; then CURRENT_BRANCH="$STATE_BRANCH" return fi die "Detached HEAD is not supported here and no saved branch state was found." } update_state_for_sync() { STATE_BRANCH="$CURRENT_BRANCH" STATE_BACKUP_BRANCH="$BACKUP_BRANCH" STATE_STASH_HASH="$STASH_HASH" STATE_REMOTE_NAME="$REMOTE_NAME" STATE_REMOTE_URL="$REMOTE_URL" STATE_TARGET_BRANCH="$BRANCH" write_state } list_current_branch_backup_refs() { local safe_branch_name safe_branch_name="$(sanitize_ref_name "$CURRENT_BRANCH")" git for-each-ref --sort=-creatordate --format='%(refname:short)' "refs/heads/backup/${safe_branch_name}-*" } show_current_branch_backups() { local backup_ref if ! list_current_branch_backup_refs | grep -q .; then say "No existing backups were found for branch '$CURRENT_BRANCH'." return 1 fi say "Existing backups for branch '$CURRENT_BRANCH':" while IFS= read -r backup_ref; do [ -n "$backup_ref" ] || continue say " - $backup_ref" done < <(list_current_branch_backup_refs) return 0 } confirm_yes_no() { local prompt="$1" reply while true; do printf '%s [Y/n]: ' "$prompt" read -r reply case "${reply,,}" in ""|y|yes) return 0 ;; n|no) return 1 ;; *) say "Please answer yes or no." ;; esac done } prepare_backup_before_real_action() { local action_label="$1" backup_reason="$2" latest_existing_backup="" STATE_START_HEAD="$(git rev-parse HEAD)" STATE_SNAPSHOT_PATH="" BACKUP_BRANCH="" latest_existing_backup="$(list_current_branch_backup_refs | head -n 1 || true)" if [ -z "$latest_existing_backup" ]; then BACKUP_BRANCH="$(create_backup_branch_for_reason "$backup_reason")" say "No existing backups were found, so a new backup was created automatically: $BACKUP_BRANCH" else show_current_branch_backups if [ -t 0 ] && [ -t 1 ]; then if confirm_yes_no "Create a fresh backup before '$action_label'?"; then BACKUP_BRANCH="$(create_backup_branch_for_reason "$backup_reason")" say "New backup created: $BACKUP_BRANCH" else BACKUP_BRANCH="$latest_existing_backup" say "No new backup created. The exact current commit was still recorded internally as: $STATE_START_HEAD" say "Latest existing backup selected as reference: $BACKUP_BRANCH" fi else BACKUP_BRANCH="$(create_backup_branch_for_reason "$backup_reason")" say "Non-interactive mode detected. A fresh backup was created automatically: $BACKUP_BRANCH" fi fi STATE_SNAPSHOT_PATH="$(create_worktree_snapshot_for_reason "$backup_reason")" say "Exact worktree snapshot created: $STATE_SNAPSHOT_PATH" } ensure_upstream_remote() { local current_remote_url if current_remote_url="$(git remote get-url "$REMOTE_NAME" 2>/dev/null)"; then if [ "$current_remote_url" != "$REMOTE_URL" ]; then die "Remote '$REMOTE_NAME' points to '$current_remote_url', expected '$REMOTE_URL'." fi return fi say "Adding remote '$REMOTE_NAME'..." git remote add "$REMOTE_NAME" "$REMOTE_URL" } show_status() { local stash_count latest_backup latest_snapshot dry_run_branch_dir ensure_git_repo ensure_current_branch ensure_upstream_remote stash_count="$(git stash list | wc -l | tr -d ' ')" say "Repository: $(git rev-parse --show-toplevel)" say "Current branch: $CURRENT_BRANCH" say "Upstream remote: $REMOTE_NAME -> $REMOTE_URL" say "Working tree:" if ! worktree_has_local_changes; then say " clean" else git status --short fi say "Stash entries: $stash_count" latest_backup="$(find_latest_backup_branch_for_current || true)" say "Latest backup for current branch: ${latest_backup:-none}" latest_snapshot="$(find_latest_snapshot_for_current || true)" say "Latest snapshot for current branch: ${latest_snapshot:-none}" dry_run_branch_dir="$DRY_RUN_ROOT/$(sanitize_ref_name "$CURRENT_BRANCH")" if [ -d "$dry_run_branch_dir/repo/.git" ]; then say "Dry-run workspace: $dry_run_branch_dir" else say "Dry-run workspace: not initialized" fi if load_state; then say "Saved sync state: $STATE_STATUS" say "Saved backup branch: ${STATE_BACKUP_BRANCH:-none}" say "Saved starting commit: ${STATE_START_HEAD:-none}" say "Saved original stash: ${STATE_STASH_HASH:-none}" say "Saved exact snapshot: ${STATE_SNAPSHOT_PATH:-none}" else say "Saved sync state: none" fi } create_backup_branch_for_reason() { local reason="$1" safe_branch_name timestamp branch_name safe_branch_name="${CURRENT_BRANCH//\//-}" timestamp="$(date +%Y%m%d-%H%M%S)" branch_name="backup/${safe_branch_name}-${reason}-${timestamp}" git branch "$branch_name" HEAD >/dev/null printf '%s\n' "$branch_name" } create_backup_branch() { BACKUP_BRANCH="$(create_backup_branch_for_reason "before-upstream-sync")" say "Backup branch created: $BACKUP_BRANCH" } stash_current_changes() { local stash_prefix="$1" timestamp stash_name STASH_REF="" STASH_HASH="" if ! worktree_has_local_changes; then say "No local tracked/untracked changes to stash." return fi timestamp="$(date +%Y%m%d-%H%M%S)" stash_name="${stash_prefix}-${CURRENT_BRANCH//\//-}-${timestamp}" say "Stashing local tracked and untracked changes..." git stash push --include-untracked --message "$stash_name" >/dev/null STASH_REF="$(git stash list -1 --format='%gd')" [ -n "$STASH_REF" ] || die "Failed to locate the created stash entry." STASH_HASH="$(git rev-parse "$STASH_REF")" [ -n "$STASH_HASH" ] || die "Failed to resolve the created stash hash." say "Local changes saved in $STASH_REF" } stash_local_changes() { stash_current_changes "pre-upstream-sync" } fetch_upstream() { say "Fetching latest changes from $REMOTE_NAME/$BRANCH..." git fetch --prune "$REMOTE_NAME" "+refs/heads/$BRANCH:refs/remotes/$REMOTE_NAME/$BRANCH" git show-ref --verify --quiet "refs/remotes/$REMOTE_NAME/$BRANCH" || die "Remote branch '$REMOTE_NAME/$BRANCH' was not found." } show_rebase_conflict_details() { local repo_path="$1" conflict_label conflict_file found_conflicts=0 conflict_label="$(get_rebase_conflict_commit_label "$repo_path")" if [ -n "$conflict_label" ]; then say "Conflicting local commit: $conflict_label" fi while IFS= read -r conflict_file; do if [ "$found_conflicts" -eq 0 ]; then say "Conflicted files:" found_conflicts=1 fi say " - $conflict_file" if [ "$conflict_file" = "package.json" ]; then show_package_json_conflict_details "$repo_path" "$conflict_file" fi done < <(git -C "$repo_path" diff --name-only --diff-filter=U) if [ "$found_conflicts" -eq 0 ]; then say "No explicit conflicted files were detected, but the rebase did stop." fi } get_rebase_conflict_commit_label() { local repo_path="$1" rebase_head rebase_head="$(git -C "$repo_path" rev-parse --short REBASE_HEAD 2>/dev/null || true)" if [ -n "$rebase_head" ]; then git -C "$repo_path" show --no-patch --format='%h %s' REBASE_HEAD fi } array_contains_item() { local item="$1" existing shift || true for existing in "$@"; do if [ "$existing" = "$item" ]; then return 0 fi done return 1 } extract_package_json_scripts_block() { sed -n '/"scripts"[[:space:]]*:[[:space:]]*{/,/^[[:space:]]*}[[:space:]]*,\{0,1\}[[:space:]]*$/p' } show_package_json_conflict_details() { local repo_path="$1" file_path="$2" upstream_side conflict_commit_side current_branch_side upstream_side="$(git -C "$repo_path" show ":2:$file_path" 2>/dev/null || true)" conflict_commit_side="$(git -C "$repo_path" show ":3:$file_path" 2>/dev/null || true)" current_branch_side="$(cat "$REPO_ROOT/$file_path" 2>/dev/null || true)" say " package.json scripts comparison:" if [ -n "$upstream_side" ]; then say " Upstream/current side:" printf '%s\n' "$upstream_side" | extract_package_json_scripts_block | sed 's/^/ /' fi if [ -n "$conflict_commit_side" ]; then say " Conflicting commit side:" printf '%s\n' "$conflict_commit_side" | extract_package_json_scripts_block | sed 's/^/ /' fi if [ -n "$current_branch_side" ]; then say " Current branch working tree side:" printf '%s\n' "$current_branch_side" | extract_package_json_scripts_block | sed 's/^/ /' fi } auto_resolve_current_rebase_conflicts_in_temp_clone() { local repo_path="$1" conflict_file while IFS= read -r conflict_file; do if ! git -C "$repo_path" checkout --theirs -- "$conflict_file" 2>/dev/null; then git -C "$repo_path" rm --force -- "$conflict_file" >/dev/null 2>&1 || true fi git -C "$repo_path" add -A -- "$conflict_file" done < <(git -C "$repo_path" diff --name-only --diff-filter=U) } collect_all_rebase_conflicts_in_temp_clone() { local repo_path="$1" conflict_count=0 commit_label conflict_file local -a conflict_commits=() local -a unique_conflict_files=() while has_rebase_in_progress_in_repo "$repo_path"; do conflict_count=$((conflict_count + 1)) commit_label="$(get_rebase_conflict_commit_label "$repo_path")" if [ -n "$commit_label" ] && ! array_contains_item "$commit_label" "${conflict_commits[@]}"; then conflict_commits+=("$commit_label") fi while IFS= read -r conflict_file; do [ -n "$conflict_file" ] || continue if ! array_contains_item "$conflict_file" "${unique_conflict_files[@]}"; then unique_conflict_files+=("$conflict_file") fi done < <(git -C "$repo_path" diff --name-only --diff-filter=U) say "Conflict step #$conflict_count:" show_rebase_conflict_details "$repo_path" auto_resolve_current_rebase_conflicts_in_temp_clone "$repo_path" if ! GIT_EDITOR=: git -C "$repo_path" rebase --continue >/dev/null 2>&1; then if has_rebase_in_progress_in_repo "$repo_path"; then continue fi say "Dry-run could not continue after automatically recording a conflict step." return 1 fi done say "Total detected rebase conflict steps: $conflict_count" if [ "${#conflict_commits[@]}" -gt 0 ]; then say "Local commits with conflicts (${#conflict_commits[@]}):" for commit_label in "${conflict_commits[@]}"; do say " - $commit_label" done fi if [ "${#unique_conflict_files[@]}" -gt 0 ]; then say "Unique conflicted files (${#unique_conflict_files[@]}):" for conflict_file in "${unique_conflict_files[@]}"; do say " - $conflict_file" done fi return 0 } prepare_dry_run_workspace() { local dry_run_branch_dir="$1" dry_run_repo="$2" mkdir -p "$dry_run_branch_dir" if [ ! -d "$dry_run_repo/.git" ]; then say "Creating reusable dry-run clone in $dry_run_repo..." git clone --quiet --no-local --branch "$CURRENT_BRANCH" --single-branch "$REPO_ROOT" "$dry_run_repo" else say "Reusing dry-run clone in $dry_run_repo..." if has_rebase_in_progress_in_repo "$dry_run_repo"; then git -C "$dry_run_repo" rebase --abort >/dev/null 2>&1 || true fi git -C "$dry_run_repo" reset --hard >/dev/null git -C "$dry_run_repo" clean -fdx >/dev/null git -C "$dry_run_repo" remote set-url origin "$REPO_ROOT" git -C "$dry_run_repo" fetch --prune origin "$CURRENT_BRANCH" git -C "$dry_run_repo" checkout -B "$CURRENT_BRANCH" "origin/$CURRENT_BRANCH" >/dev/null 2>&1 fi if git -C "$dry_run_repo" remote get-url "$REMOTE_NAME" >/dev/null 2>&1; then git -C "$dry_run_repo" remote set-url "$REMOTE_NAME" "$REMOTE_URL" else git -C "$dry_run_repo" remote add "$REMOTE_NAME" "$REMOTE_URL" fi } run_clean_dry_run() { local tmp_root legacy_dir removed_any=0 ensure_git_repo if [ -d "$DRY_RUN_ROOT" ]; then rm -rf "$DRY_RUN_ROOT" say "Removed reusable dry-run workspace: $DRY_RUN_ROOT" removed_any=1 fi tmp_root="${TMPDIR:-/tmp}" shopt -s nullglob for legacy_dir in "$tmp_root"/gitea-upstream-test.*; do if [ -d "$legacy_dir" ]; then rm -rf "$legacy_dir" say "Removed legacy dry-run directory: $legacy_dir" removed_any=1 fi done shopt -u nullglob if [ "$removed_any" -eq 0 ]; then say "No dry-run workspace or legacy temporary directories were found." fi } rebase_onto_upstream() { say "Rebasing '$CURRENT_BRANCH' onto '$REMOTE_NAME/$BRANCH'..." if git rebase "$REMOTE_NAME/$BRANCH"; then say "Rebase completed successfully." STATE_STATUS="rebase_completed" update_state_for_sync return fi STATE_STATUS="rebase_conflict" update_state_for_sync say "Rebase stopped because of conflicts." show_rebase_conflict_details "." say "Backup branch kept at: $BACKUP_BRANCH" if [ -n "$STASH_HASH" ]; then say "Local stash kept at: $STASH_HASH" fi say "Resolve conflicts and run 'git rebase --continue', or abort with 'git rebase --abort'." exit 1 } find_stash_ref_by_hash() { local target_hash="$1" hash ref while read -r hash ref; do if [ "$hash" = "$target_hash" ]; then printf '%s\n' "$ref" return 0 fi done < <(git stash list --format='%H %gd') return 1 } drop_stash_by_hash_if_present() { local target_hash="$1" stash_ref [ -n "$target_hash" ] || return 0 if stash_ref="$(find_stash_ref_by_hash "$target_hash")"; then git stash drop "$stash_ref" >/dev/null fi } restore_stash() { local keep_saved_copy="${1:-0}" restore_target if [ -z "$STASH_HASH" ] && [ -z "$STASH_REF" ]; then return fi restore_target="${STASH_HASH:-$STASH_REF}" say "Restoring stashed local changes from $restore_target..." if git stash apply --index "$restore_target"; then STASH_REF="" if [ "$keep_saved_copy" -eq 0 ]; then drop_stash_by_hash_if_present "$STASH_HASH" STASH_HASH="" STATE_STASH_HASH="" else STATE_STASH_HASH="${STASH_HASH:-$restore_target}" fi STATE_STATUS="completed" update_state_for_sync if [ "$keep_saved_copy" -eq 0 ]; then say "Local changes restored successfully." else say "Local changes restored successfully. The original stash was kept for rollback safety." fi return fi STATE_STATUS="restore_conflict" update_state_for_sync say "Conflicts occurred while restoring local changes." say "Backup branch kept at: $BACKUP_BRANCH" say "Stash kept at: $restore_target" say "Resolve the conflicts manually. After that, drop the stash yourself if it is no longer needed." exit 1 } find_latest_backup_branch_for_current() { local safe_branch_name safe_branch_name="$(sanitize_ref_name "$CURRENT_BRANCH")" git for-each-ref --sort=-creatordate --format='%(refname:short)' "refs/heads/backup/${safe_branch_name}-before-upstream-sync-*" | head -n 1 } run_list_backups() { ensure_git_repo if ! git for-each-ref --format='%(refname:short)' refs/heads/backup/ | grep -q .; then say "No backup branches found." return fi git for-each-ref --sort=-creatordate \ --format='%(refname:short) | %(creatordate:iso8601-local) | %(objectname:short) | %(subject)' \ refs/heads/backup/ } run_restore_saved_stash() { ensure_git_repo ensure_no_git_operation_in_progress ensure_current_branch load_state || die "No saved sync state was found." if [ -n "${STATE_BRANCH:-}" ] && [ "$CURRENT_BRANCH" != "$STATE_BRANCH" ]; then die "Saved sync state belongs to branch '$STATE_BRANCH'. Check out that branch before restoring the saved stash." fi [ -n "$STATE_STASH_HASH" ] || die "There is no saved pre-sync stash to restore." if worktree_has_local_changes; then die "The working tree is not clean. Commit, stash, or discard current changes before restoring the saved stash." fi prepare_backup_before_real_action "restore-stash" "before-restore-stash" STASH_HASH="$STATE_STASH_HASH" STASH_REF="" restore_stash 1 say "Saved pre-sync local changes restored." } run_rollback() { local explicit_target=0 target_backup="${1:-}" rollback_safety_backup rollback_safety_snapshot="" local reset_target="" reset_snapshot="" preserve_stash_hash="" local original_restore_stash_hash="" original_restore_start_head="" local rollback_safety_start_head="" local explicit_snapshot=0 ensure_git_repo ensure_no_nonrebase_git_operation_in_progress load_state || true ensure_current_branch_with_state_fallback ensure_upstream_remote if [ -n "$target_backup" ]; then explicit_target=1 if is_valid_snapshot_dir "$target_backup"; then explicit_snapshot=1 reset_snapshot="$target_backup" fi fi if [ "$explicit_target" -eq 0 ] && [ -n "${STATE_BRANCH:-}" ] && [ "$CURRENT_BRANCH" != "$STATE_BRANCH" ]; then die "Saved sync state belongs to branch '$STATE_BRANCH'. Check out that branch or pass an explicit backup branch." fi if [ "$explicit_snapshot" -eq 0 ] && [ "$explicit_target" -eq 0 ] && [ -n "${STATE_SNAPSHOT_PATH:-}" ] && is_valid_snapshot_dir "$STATE_SNAPSHOT_PATH"; then reset_snapshot="$STATE_SNAPSHOT_PATH" fi if [ "$explicit_snapshot" -eq 0 ] && [ "$explicit_target" -eq 0 ] && [ -n "${STATE_START_HEAD:-}" ]; then reset_target="$STATE_START_HEAD" fi if [ -z "$reset_target" ] && [ -z "$reset_snapshot" ] && [ -z "$target_backup" ]; then target_backup="${STATE_BACKUP_BRANCH:-}" fi if [ -z "$reset_target" ] && [ -z "$reset_snapshot" ] && [ -z "$target_backup" ]; then target_backup="$(find_latest_backup_branch_for_current || true)" fi if [ -z "$reset_target" ] && [ -z "$reset_snapshot" ]; then [ -n "$target_backup" ] || die "No backup branch was found for rollback." git show-ref --verify --quiet "refs/heads/$target_backup" || die "Backup branch '$target_backup' does not exist." reset_target="$target_backup" fi original_restore_stash_hash="${STATE_STASH_HASH:-}" original_restore_start_head="${STATE_START_HEAD:-}" if has_rebase_in_progress; then say "Aborting in-progress rebase before rollback..." git rebase --abort || die "Failed to abort the in-progress rebase." fi if [ -n "$(git diff --name-only --diff-filter=U)" ]; then die "There are still unmerged files in the working tree. Resolve or discard them before rollback." fi prepare_backup_before_real_action "rollback" "before-rollback" rollback_safety_backup="$BACKUP_BRANCH" rollback_safety_snapshot="$STATE_SNAPSHOT_PATH" rollback_safety_start_head="$STATE_START_HEAD" preserve_stash_hash="" if worktree_has_local_changes; then stash_current_changes "pre-rollback-preserve" preserve_stash_hash="$STASH_HASH" fi if [ -n "$reset_target" ]; then say "Resetting '$CURRENT_BRANCH' to '$reset_target'..." git reset --hard "$reset_target" >/dev/null fi if [ -n "$original_restore_stash_hash" ]; then STASH_HASH="$original_restore_stash_hash" STASH_REF="" BACKUP_BRANCH="$rollback_safety_backup" say "Restoring original pre-sync local changes..." restore_stash 1 fi if [ -n "$reset_snapshot" ]; then say "Restoring exact worktree snapshot from: $reset_snapshot" restore_worktree_snapshot "$reset_snapshot" fi STATE_BRANCH="$CURRENT_BRANCH" STATE_BACKUP_BRANCH="$rollback_safety_backup" STATE_STASH_HASH="$preserve_stash_hash" STATE_REMOTE_NAME="$REMOTE_NAME" STATE_REMOTE_URL="$REMOTE_URL" STATE_TARGET_BRANCH="$BRANCH" STATE_START_HEAD="$rollback_safety_start_head" STATE_SNAPSHOT_PATH="$rollback_safety_snapshot" STATE_STATUS="rollback_completed" write_state say "Rollback completed." if [ -n "$reset_snapshot" ]; then say "Current branch and worktree restored from saved state." say "Restored starting commit: ${original_restore_start_head:-$reset_target}" say "Restored snapshot: $reset_snapshot" else say "Current branch restored from: $reset_target" fi say "Safety backup of the pre-rollback state kept at: $rollback_safety_backup" say "Safety snapshot of the pre-rollback state kept at: $rollback_safety_snapshot" if [ -n "$preserve_stash_hash" ]; then say "Safety stash of the pre-rollback local changes kept at: $preserve_stash_hash" fi } run_restore_snapshot() { local target_snapshot="${1:-}" restore_safety_backup restore_safety_snapshot="" local preserve_stash_hash="" restore_start_head="" restore_safety_start_head="" ensure_git_repo ensure_no_nonrebase_git_operation_in_progress load_state || die "No saved sync state was found." ensure_current_branch_with_state_fallback ensure_upstream_remote if [ -z "$target_snapshot" ]; then target_snapshot="${STATE_SNAPSHOT_PATH:-}" fi is_valid_snapshot_dir "$target_snapshot" || die "No valid saved snapshot was found to restore." restore_start_head="${STATE_START_HEAD:-}" [ -n "$restore_start_head" ] || die "No saved starting commit was found for the snapshot restore." if has_rebase_in_progress; then say "Aborting in-progress rebase before snapshot restore..." git rebase --abort || die "Failed to abort the in-progress rebase." fi if [ -n "$(git diff --name-only --diff-filter=U)" ]; then die "There are still unmerged files in the working tree. Resolve or discard them before restoring the snapshot." fi prepare_backup_before_real_action "restore-snapshot" "before-restore-snapshot" restore_safety_backup="$BACKUP_BRANCH" restore_safety_snapshot="$STATE_SNAPSHOT_PATH" restore_safety_start_head="$STATE_START_HEAD" if worktree_has_local_changes; then stash_current_changes "pre-restore-snapshot-preserve" preserve_stash_hash="$STASH_HASH" fi say "Resetting '$CURRENT_BRANCH' to '$restore_start_head' before snapshot restore..." git reset --hard "$restore_start_head" >/dev/null if [ -n "${STATE_STASH_HASH:-}" ]; then STASH_HASH="$STATE_STASH_HASH" STASH_REF="" BACKUP_BRANCH="$restore_safety_backup" say "Restoring saved pre-sync local changes..." restore_stash 1 fi say "Restoring exact worktree snapshot from: $target_snapshot" restore_worktree_snapshot "$target_snapshot" STATE_BRANCH="$CURRENT_BRANCH" STATE_BACKUP_BRANCH="$restore_safety_backup" STATE_STASH_HASH="$preserve_stash_hash" STATE_REMOTE_NAME="$REMOTE_NAME" STATE_REMOTE_URL="$REMOTE_URL" STATE_TARGET_BRANCH="$BRANCH" STATE_START_HEAD="$restore_safety_start_head" STATE_SNAPSHOT_PATH="$restore_safety_snapshot" STATE_STATUS="snapshot_restored" write_state say "Snapshot restore completed." say "Safety backup of the pre-restore state kept at: $restore_safety_backup" say "Safety snapshot of the pre-restore state kept at: $restore_safety_snapshot" if [ -n "$preserve_stash_hash" ]; then say "Safety stash of the pre-restore local changes kept at: $preserve_stash_hash" fi } run_dry_run_sync() { local dry_run_branch_dir temp_repo patch_file has_tracked_changes has_untracked_changes local dry_run_has_conflicts=0 untracked_conflicts=0 path ensure_git_repo ensure_no_git_operation_in_progress ensure_current_branch ensure_upstream_remote dry_run_branch_dir="$DRY_RUN_ROOT/$(sanitize_ref_name "$CURRENT_BRANCH")" temp_repo="$dry_run_branch_dir/repo" patch_file="$dry_run_branch_dir/local-changes.patch" has_tracked_changes=0 has_untracked_changes=0 prepare_dry_run_workspace "$dry_run_branch_dir" "$temp_repo" say "Testing upstream fetch in the dry-run clone..." git -C "$temp_repo" fetch --prune "$REMOTE_NAME" "+refs/heads/$BRANCH:refs/remotes/$REMOTE_NAME/$BRANCH" git -C "$temp_repo" show-ref --verify --quiet "refs/remotes/$REMOTE_NAME/$BRANCH" || die "Remote branch '$REMOTE_NAME/$BRANCH' was not found during test." say "Testing rebase in the dry-run clone..." if ! git -C "$temp_repo" rebase "$REMOTE_NAME/$BRANCH"; then say "Dry-run failed during rebase." if ! collect_all_rebase_conflicts_in_temp_clone "$temp_repo"; then say "Dry-run could not enumerate all remaining rebase conflicts." say "Current repository was not modified." say "Dry-run workspace kept at: $temp_repo" exit 1 fi dry_run_has_conflicts=1 fi if ! git diff --quiet HEAD || ! git diff --cached --quiet; then has_tracked_changes=1 git diff --binary HEAD >"$patch_file" if [ -s "$patch_file" ]; then say "Testing re-apply of tracked local changes..." if ! git -C "$temp_repo" apply --3way --index "$patch_file"; then say "Dry-run detected tracked local-change conflicts during re-apply." dry_run_has_conflicts=1 fi fi fi if [ -n "$(git ls-files --others --exclude-standard)" ]; then has_untracked_changes=1 say "Testing collisions for untracked local files..." while IFS= read -r -d '' path; do if [ -e "$temp_repo/$path" ]; then say "Untracked file would conflict after sync: $path" untracked_conflicts=1 fi done < <(git ls-files --others --exclude-standard -z) fi if [ "$untracked_conflicts" -ne 0 ]; then say "Dry-run detected untracked-file conflicts." dry_run_has_conflicts=1 fi if [ "$dry_run_has_conflicts" -ne 0 ]; then say "Dry-run completed with conflicts detected." say "A real sync would require manual conflict resolution." say "Current repository was not modified." say "Dry-run workspace kept at: $temp_repo" return 0 fi if [ "$has_tracked_changes" -eq 0 ] && [ "$has_untracked_changes" -eq 0 ]; then say "Dry-run passed. No local changes needed to be re-applied." else say "Dry-run passed. Local changes appear safe to restore after sync." fi say "Dry-run workspace ready for reuse at: $temp_repo" } run_sync() { ensure_git_repo ensure_no_git_operation_in_progress ensure_current_branch ensure_upstream_remote prepare_backup_before_real_action "sync" "before-upstream-sync" stash_local_changes STATE_STATUS="prepared" update_state_for_sync fetch_upstream rebase_onto_upstream restore_stash 1 say "Project is now synchronized with $REMOTE_NAME/$BRANCH." say "Safety backup branch available at: $BACKUP_BRANCH" say "Safety snapshot available at: $STATE_SNAPSHOT_PATH" if [ -n "$STATE_STASH_HASH" ]; then say "Original pre-sync stash retained for full rollback at: $STATE_STASH_HASH" fi } run_fetch_only() { ensure_git_repo ensure_no_git_operation_in_progress ensure_current_branch ensure_upstream_remote fetch_upstream say "Latest changes fetched from $REMOTE_NAME/$BRANCH." } run_backup_only() { ensure_git_repo ensure_no_git_operation_in_progress ensure_current_branch ensure_upstream_remote create_backup_branch } show_menu() { local choice while true; do cat <