#!/usr/bin/env bash set -euo pipefail # This script maintains a custom release branch on top of an upstream release # line while keeping your own local custom commits. # It is designed for flows such as: # - base tag: v1.26.0-rc0 # - upstream release line: release/v1.26 # - custom maintenance: release/v1.26-custom # - custom release tags: v1.26.0-custom, v1.26.1-custom # It: # 1. fetches the upstream release line and upstream compare branch; # 2. creates or reuses a persistent custom maintenance branch; # 3. cherry-picks missing upstream release commits onto that branch; # 4. cherry-picks only your custom commits from your source branch; # 5. keeps a backup branch, exact worktree snapshot, and saved stash so the # original state can be restored safely if something is not OK. BASE_TAG="${BASE_TAG:-v1.26.0-rc0}" UPSTREAM_RELEASE_BRANCH="${UPSTREAM_RELEASE_BRANCH:-release/v1.26}" UPSTREAM_RELEASE_TARGET_REF="${UPSTREAM_RELEASE_TARGET_REF:-}" MAINTENANCE_BRANCH="${MAINTENANCE_BRANCH:-release/v1.26-custom}" CUSTOM_SOURCE_BRANCH="${CUSTOM_SOURCE_BRANCH:-main}" UPSTREAM_COMPARE_BRANCH="${UPSTREAM_COMPARE_BRANCH:-main}" CUSTOM_TAG_SUFFIX="${CUSTOM_TAG_SUFFIX:--custom}" REMOTE_NAME="${REMOTE_NAME:-upstream}" REMOTE_URL="${REMOTE_URL:-https://github.com/go-gitea/gitea.git}" RUNTIME_ENV_FILE="${RUNTIME_ENV_FILE:-/tmp/.maintain-custom-release.env}" DEFAULT_BASE_TAG="$BASE_TAG" DEFAULT_UPSTREAM_RELEASE_BRANCH="$UPSTREAM_RELEASE_BRANCH" DEFAULT_UPSTREAM_RELEASE_TARGET_REF="$UPSTREAM_RELEASE_TARGET_REF" DEFAULT_MAINTENANCE_BRANCH="$MAINTENANCE_BRANCH" DEFAULT_CUSTOM_SOURCE_BRANCH="$CUSTOM_SOURCE_BRANCH" DEFAULT_UPSTREAM_COMPARE_BRANCH="$UPSTREAM_COMPARE_BRANCH" DEFAULT_CUSTOM_TAG_SUFFIX="$CUSTOM_TAG_SUFFIX" REPO_ROOT="" GIT_DIR="" STATE_FILE="" SNAPSHOT_ROOT="" RESTORE_POINT_ROOT="" LEGACY_RESTORE_POINT_ROOT="" CURRENT_BRANCH="" BACKUP_BRANCH="" STASH_REF="" STASH_HASH="" STATE_BRANCH="" STATE_ENTRY_BRANCH="" STATE_BACKUP_BRANCH="" STATE_STASH_HASH="" STATE_REMOTE_NAME="" STATE_REMOTE_URL="" STATE_BASE_TAG="" STATE_UPSTREAM_RELEASE_BRANCH="" STATE_UPSTREAM_RELEASE_TARGET_REF="" STATE_UPSTREAM_COMPARE_BRANCH="" STATE_CUSTOM_SOURCE_BRANCH="" STATE_MAINTENANCE_BRANCH="" STATE_START_HEAD="" STATE_SNAPSHOT_PATH="" STATE_PENDING_COMMITS="" STATE_ACTION_LABEL="" STATE_STATUS="" STATE_FETCH_REF_STATES="" STATE_CREATED_MAINTENANCE_BRANCH="" STATE_UPDATED_AT="" FETCH_REF_STATES="" MAINTENANCE_BRANCH_WAS_CREATED="0" SELECTED_RESTORE_POINT="" SELECTED_STASH_TARGET="" SELECTED_SNAPSHOT_TARGET="" say() { printf '%s\n' "$*" } die() { say "ERROR: $*" >&2 exit 1 } save_runtime_settings() { cat >"$RUNTIME_ENV_FILE" <} MAINTENANCE_BRANCH=$MAINTENANCE_BRANCH CUSTOM_SOURCE_BRANCH=$CUSTOM_SOURCE_BRANCH UPSTREAM_COMPARE_BRANCH=$UPSTREAM_COMPARE_BRANCH CUSTOM_TAG_SUFFIX=$CUSTOM_TAG_SUFFIX Available commands: configure Interactively update the current in-memory config values for this run. create-restore-point [label] Create a manual restore point for the current state, including a backup branch and an exact worktree snapshot. list-restore-points List available manual restore points created by this script. restore-point [restore-point-dir-or-name] Restore a manual restore point by path or by its final directory name. If omitted, the latest point for the current branch is used. delete-restore-point Delete a manual restore point and its associated backup branch. status Show the current config, branch state, and saved recovery metadata. bootstrap Create or switch to the maintenance branch from BASE_TAG without importing any commits. fetch Fetch both the upstream compare branch and the upstream release target ref. fetch-upstream-compare Fetch only upstream/$UPSTREAM_COMPARE_BRANCH. fetch-upstream-target Fetch only the configured upstream release target ref, or upstream/$UPSTREAM_RELEASE_BRANCH when no target ref is set. plan Show which upstream release commits and which custom commits would be imported next. sync-upstream-compare Import only the missing commits from upstream/\$UPSTREAM_COMPARE_BRANCH, usually upstream/main. sync-upstream-target Import only the missing commits from the configured upstream release target ref, or from upstream/\$UPSTREAM_RELEASE_BRANCH when no target ref is set. sync-custom Import only your custom commits from \$CUSTOM_SOURCE_BRANCH, cherry-picking them from oldest to newest. sync-all Import missing upstream compare-branch commits, then upstream release-target commits, and finally your custom commits. continue Continue an interrupted cherry-pick sequence and finish any remaining queued commits. rollback [backup-branch|snapshot-dir] Restore the saved pre-import state, or an explicit backup branch / snapshot. restore-stash Re-apply the saved pre-import local stash from the last recorded state. restore-snapshot [snapshot-dir] Restore the exact saved repository snapshot from the last recorded real action, or from a specific snapshot directory. tag Create an annotated custom release tag like \$CUSTOM_TAG_SUFFIX on the maintenance branch. help Show this help text. If no command is provided and the script is run from a terminal, an interactive menu is shown. Interactive menu structure: - Manual Backups > create, list, restore, delete manual restore points - Fetch upstreams > fetch upstream/main compare ref or the configured release target ref - Sync > sync compare commits, release-target commits, custom commits, or all - Rollback > choose the saved rollback state, a saved backup branch, or a snapshot - Restore > restore the saved stash or an exact snapshot, or open Delete Restore > In every submenu: - press Enter for Back - or type b / B for Back - type 0 for Exit Recommended order: 1. Run: ./.maintain-custom-release.sh status 2. Optional but recommended: ./.maintain-custom-release.sh create-restore-point before-custom-release-work 3. First release on a new series: - run: ./.maintain-custom-release.sh bootstrap 4. Fetch what you need: - upstream/main or your configured compare branch: ./.maintain-custom-release.sh fetch-upstream-compare - upstream release target ref: ./.maintain-custom-release.sh fetch-upstream-target - or both: ./.maintain-custom-release.sh fetch 5. Run: ./.maintain-custom-release.sh plan 6. Then import in the order you want: - upstream/main or your configured compare branch first: ./.maintain-custom-release.sh sync-upstream-compare - upstream release target next: ./.maintain-custom-release.sh sync-upstream-target - only your custom commits: ./.maintain-custom-release.sh sync-custom - or both in one step: ./.maintain-custom-release.sh sync-all - for a fixed upstream release such as v1.26.0, set UPSTREAM_RELEASE_TARGET_REF first 7. Later patch releases on the same series: - keep the same upstream minor release branch, for example release/v1.26 - run the sync step you want, usually: ./.maintain-custom-release.sh sync-all 8. If cherry-pick stops with conflicts: - either resolve them and continue with: ./.maintain-custom-release.sh continue - or return safely with: ./.maintain-custom-release.sh rollback 9. If you want to return to an explicitly saved manual checkpoint: - run: ./.maintain-custom-release.sh restore-point 10. When the branch looks correct: - run: ./.maintain-custom-release.sh tag v1.26.0 which creates: v1.26.0-custom EOF } prompt_with_default() { local prompt="$1" current_value="$2" reply="" printf '%s [%s]: ' "$prompt" "$current_value" >&2 read -r reply if [ -n "$reply" ]; then printf '%s\n' "$reply" else printf '%s\n' "$current_value" fi } prompt_with_default_allow_clear() { local prompt="$1" current_value="$2" reply="" printf '%s [%s]: ' "$prompt" "$current_value" >&2 read -r reply if [ "$reply" = "-" ]; then printf '\n' elif [ -n "$reply" ]; then printf '%s\n' "$reply" else printf '%s\n' "$current_value" fi } self_update_external_copy_if_needed() { local repo_root="" invoked_path="" canonical_path="" tmp_runtime_path="/tmp/.maintain-custom-release.sh" repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" case "$0" in /*) invoked_path="$0" ;; *) invoked_path="$(pwd -P)/$0" ;; esac [ -f "$invoked_path" ] || invoked_path="$tmp_runtime_path" if [ -f "$invoked_path" ]; then invoked_path="$(cd "$(dirname "$invoked_path")" && pwd -P)/$(basename "$invoked_path")" fi if [ -n "$repo_root" ]; then canonical_path="$(cd "$repo_root" && pwd -P)/.maintain-custom-release.sh" else canonical_path="" fi if [ -n "$canonical_path" ] && [ -f "$canonical_path" ]; then if [ ! -f "$tmp_runtime_path" ] || ! cmp -s "$tmp_runtime_path" "$canonical_path"; then cp "$canonical_path" "$tmp_runtime_path" chmod +x "$tmp_runtime_path" >/dev/null 2>&1 || true say "Updated the temporary runtime script copy: $tmp_runtime_path" fi if [ "$invoked_path" != "$tmp_runtime_path" ]; then exec "$tmp_runtime_path" "$@" fi return 0 fi if [ -f "$tmp_runtime_path" ] && [ "$invoked_path" != "$tmp_runtime_path" ]; then say "Repo copy is unavailable in the current checkout. Relaunching from the temporary runtime copy: $tmp_runtime_path" exec "$tmp_runtime_path" "$@" fi return 0 } normalize_upstream_release_branch() { local fallback_branch="" if git rev-parse --verify "refs/remotes/$REMOTE_NAME/$UPSTREAM_RELEASE_BRANCH" >/dev/null 2>&1; then return fi if [[ "$UPSTREAM_RELEASE_BRANCH" =~ ^release/v([0-9]+)\.([0-9]+)\.[0-9]+$ ]]; then fallback_branch="release/v${BASH_REMATCH[1]}.${BASH_REMATCH[2]}" if git rev-parse --verify "refs/remotes/$REMOTE_NAME/$fallback_branch" >/dev/null 2>&1; then say "Configured upstream release branch '$UPSTREAM_RELEASE_BRANCH' does not exist. Using '$fallback_branch' instead." UPSTREAM_RELEASE_BRANCH="$fallback_branch" save_runtime_settings return fi fi die "Remote branch '$REMOTE_NAME/$UPSTREAM_RELEASE_BRANCH' was not found." } ensure_git_repo() { local raw_git_dir REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || die "This script must be run inside a git repository." raw_git_dir="$(git rev-parse --git-dir)" case "$raw_git_dir" in /*) GIT_DIR="$raw_git_dir" ;; *) GIT_DIR="$REPO_ROOT/$raw_git_dir" ;; esac STATE_FILE="$GIT_DIR/.maintain-custom-release-state" SNAPSHOT_ROOT="$GIT_DIR/.maintain-custom-release-snapshots" RESTORE_POINT_ROOT="$GIT_DIR/.maintain-custom-release-restore-points" LEGACY_RESTORE_POINT_ROOT="$GIT_DIR/.manual-restore-points" } write_state() { { printf 'STATE_BRANCH=%q\n' "$STATE_BRANCH" printf 'STATE_ENTRY_BRANCH=%q\n' "$STATE_ENTRY_BRANCH" printf 'STATE_BACKUP_BRANCH=%q\n' "$STATE_BACKUP_BRANCH" printf 'STATE_STASH_HASH=%q\n' "$STATE_STASH_HASH" printf 'STATE_REMOTE_NAME=%q\n' "$STATE_REMOTE_NAME" printf 'STATE_REMOTE_URL=%q\n' "$STATE_REMOTE_URL" printf 'STATE_BASE_TAG=%q\n' "$STATE_BASE_TAG" printf 'STATE_UPSTREAM_RELEASE_BRANCH=%q\n' "$STATE_UPSTREAM_RELEASE_BRANCH" printf 'STATE_UPSTREAM_RELEASE_TARGET_REF=%q\n' "$STATE_UPSTREAM_RELEASE_TARGET_REF" printf 'STATE_UPSTREAM_COMPARE_BRANCH=%q\n' "$STATE_UPSTREAM_COMPARE_BRANCH" printf 'STATE_CUSTOM_SOURCE_BRANCH=%q\n' "$STATE_CUSTOM_SOURCE_BRANCH" printf 'STATE_MAINTENANCE_BRANCH=%q\n' "$STATE_MAINTENANCE_BRANCH" printf 'STATE_START_HEAD=%q\n' "$STATE_START_HEAD" printf 'STATE_SNAPSHOT_PATH=%q\n' "$STATE_SNAPSHOT_PATH" printf 'STATE_PENDING_COMMITS=%q\n' "$STATE_PENDING_COMMITS" printf 'STATE_ACTION_LABEL=%q\n' "$STATE_ACTION_LABEL" printf 'STATE_STATUS=%q\n' "$STATE_STATUS" printf 'STATE_FETCH_REF_STATES=%q\n' "$STATE_FETCH_REF_STATES" printf 'STATE_CREATED_MAINTENANCE_BRANCH=%q\n' "$STATE_CREATED_MAINTENANCE_BRANCH" printf 'STATE_UPDATED_AT=%q\n' "$(date +%Y-%m-%dT%H:%M:%S)" } >"$STATE_FILE" } load_state() { ensure_git_repo [ -f "$STATE_FILE" ] || return 1 # shellcheck disable=SC1090 . "$STATE_FILE" [ -n "${STATE_BASE_TAG:-}" ] && BASE_TAG="$STATE_BASE_TAG" [ -n "${STATE_UPSTREAM_RELEASE_BRANCH:-}" ] && UPSTREAM_RELEASE_BRANCH="$STATE_UPSTREAM_RELEASE_BRANCH" if [ -n "${STATE_UPSTREAM_RELEASE_TARGET_REF:-}" ] || [ "${STATE_UPSTREAM_RELEASE_TARGET_REF+x}" = "x" ]; then UPSTREAM_RELEASE_TARGET_REF="${STATE_UPSTREAM_RELEASE_TARGET_REF:-}" fi [ -n "${STATE_UPSTREAM_COMPARE_BRANCH:-}" ] && UPSTREAM_COMPARE_BRANCH="$STATE_UPSTREAM_COMPARE_BRANCH" [ -n "${STATE_CUSTOM_SOURCE_BRANCH:-}" ] && CUSTOM_SOURCE_BRANCH="$STATE_CUSTOM_SOURCE_BRANCH" [ -n "${STATE_MAINTENANCE_BRANCH:-}" ] && MAINTENANCE_BRANCH="$STATE_MAINTENANCE_BRANCH" return 0 } worktree_has_local_changes() { ! git diff --quiet || ! git diff --cached --quiet || [ -n "$(git ls-files --others --exclude-standard)" ] } has_cherry_pick_in_progress() { [ -e "$(git rev-parse --git-path CHERRY_PICK_HEAD)" ] } sanitize_ref_name() { printf '%s\n' "${1//\//-}" } sanitize_storage_name() { local raw_value="${1:-}" sanitized="" sanitized="${raw_value//\//-}" sanitized="${sanitized// /-}" sanitized="${sanitized//:/-}" sanitized="${sanitized//[^[:alnum:]._-]/-}" printf '%s\n' "$sanitized" } 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")" ] } snapshot_metadata_value() { local snapshot_dir="$1" key="$2" [ -f "$snapshot_dir/metadata.txt" ] || return 1 sed -n "s/^${key}=//p" "$snapshot_dir/metadata.txt" | head -n 1 } restore_point_refs_file() { printf '%s\n' "$1/script-managed-refs.txt" } restore_point_snapshots_file() { printf '%s\n' "$1/script-managed-snapshots.txt" } capture_script_managed_refs() { git for-each-ref --format='%(refname)|%(objectname)' \ "refs/remotes/$REMOTE_NAME" \ "refs/heads/$MAINTENANCE_BRANCH" \ "refs/heads/backup/maint-" } cleanup_current_script_managed_refs() { local current_ref current_oid while IFS='|' read -r current_ref current_oid; do [ -n "$current_ref" ] || continue git update-ref -d "$current_ref" >/dev/null 2>&1 || true done < <(capture_script_managed_refs) } save_script_managed_restore_state() { local restore_point_dir="$1" refs_file snapshots_file refs_file="$(restore_point_refs_file "$restore_point_dir")" snapshots_file="$(restore_point_snapshots_file "$restore_point_dir")" capture_script_managed_refs | sort >"$refs_file" if [ -d "$SNAPSHOT_ROOT" ]; then find "$SNAPSHOT_ROOT" -mindepth 2 -maxdepth 2 -type d -printf '%P\n' | sort >"$snapshots_file" else : >"$snapshots_file" fi if [ -f "$STATE_FILE" ]; then cp "$STATE_FILE" "$restore_point_dir/.maintain-custom-release-state.saved" else rm -f "$restore_point_dir/.maintain-custom-release-state.saved" fi } restore_script_managed_refs() { local restore_point_dir="$1" refs_file current_ref current_oid saved_oid refs_file="$(restore_point_refs_file "$restore_point_dir")" if [ ! -f "$refs_file" ]; then cleanup_current_script_managed_refs return 0 fi while IFS='|' read -r current_ref current_oid; do [ -n "$current_ref" ] || continue if ! grep -Fq "${current_ref}|" "$refs_file"; then git update-ref -d "$current_ref" >/dev/null 2>&1 || true fi done < <(capture_script_managed_refs) while IFS='|' read -r current_ref saved_oid; do [ -n "$current_ref" ] || continue [ -n "$saved_oid" ] || continue git update-ref "$current_ref" "$saved_oid" done <"$refs_file" } restore_script_managed_snapshots() { local restore_point_dir="$1" snapshots_file current_snapshot rel_path current_branch_dir snapshots_file="$(restore_point_snapshots_file "$restore_point_dir")" if [ ! -f "$snapshots_file" ]; then rm -rf "$SNAPSHOT_ROOT" return 0 fi [ -d "$SNAPSHOT_ROOT" ] || return 0 while IFS= read -r current_snapshot; do [ -n "$current_snapshot" ] || continue rel_path="${current_snapshot#$SNAPSHOT_ROOT/}" if ! grep -Fxq "$rel_path" "$snapshots_file"; then rm -rf "$current_snapshot" fi done < <(find "$SNAPSHOT_ROOT" -mindepth 2 -maxdepth 2 -type d | sort) while IFS= read -r current_branch_dir; do [ -n "$current_branch_dir" ] || continue if [ -z "$(find "$current_branch_dir" -mindepth 1 -maxdepth 1 -type d -print -quit)" ]; then rmdir "$current_branch_dir" 2>/dev/null || true fi done < <(find "$SNAPSHOT_ROOT" -mindepth 1 -maxdepth 1 -type d | sort) if [ -z "$(find "$SNAPSHOT_ROOT" -mindepth 1 -maxdepth 1 -print -quit)" ]; then rmdir "$SNAPSHOT_ROOT" 2>/dev/null || true fi } restore_script_managed_state_file() { local restore_point_dir="$1" saved_state_file saved_state_file="$restore_point_dir/.maintain-custom-release-state.saved" if [ -f "$saved_state_file" ]; then cp "$saved_state_file" "$STATE_FILE" else rm -f "$STATE_FILE" fi } cleanup_temporary_restore_artifacts() { local safety_backup_branch="${1:-}" safety_snapshot_dir="${2:-}" if [ -n "$safety_backup_branch" ] && git show-ref --verify --quiet "refs/heads/$safety_backup_branch"; then git branch -D "$safety_backup_branch" >/dev/null fi if [ -n "$safety_snapshot_dir" ] && [ -d "$safety_snapshot_dir" ]; then rm -rf "$safety_snapshot_dir" fi } 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 } find_latest_restore_point_for_current() { local validation_mode="${1:-valid}" restore_point_dir source_branch while IFS= read -r restore_point_dir; do [ -n "$restore_point_dir" ] || continue restore_point_matches_mode "$restore_point_dir" "$validation_mode" || continue source_branch="$(snapshot_metadata_value "$restore_point_dir" source_branch || true)" if [ "$source_branch" = "$CURRENT_BRANCH" ]; then printf '%s\n' "$restore_point_dir" return 0 fi done < <(list_all_restore_points) return 1 } list_all_restore_points() { { if [ -d "$RESTORE_POINT_ROOT" ]; then find "$RESTORE_POINT_ROOT" -mindepth 2 -maxdepth 2 -type d fi if [ -d "$LEGACY_RESTORE_POINT_ROOT" ]; then find "$LEGACY_RESTORE_POINT_ROOT" -mindepth 1 -maxdepth 1 -type d fi } | sort -r } is_restore_point_candidate_dir() { local target_dir="$1" [ -d "$target_dir" ] || return 1 case "$target_dir" in "$RESTORE_POINT_ROOT"/*) ;; "$LEGACY_RESTORE_POINT_ROOT"/*) ;; *) return 1 ;; esac is_valid_snapshot_dir "$target_dir" } restore_point_has_live_backup_branch() { local target_dir="$1" backup_branch="" backup_branch="$(snapshot_metadata_value "$target_dir" backup_branch || true)" [ -n "$backup_branch" ] || return 0 git show-ref --verify --quiet "refs/heads/$backup_branch" } is_valid_restore_point_dir() { local target_dir="$1" is_restore_point_candidate_dir "$target_dir" || return 1 restore_point_has_live_backup_branch "$target_dir" } restore_point_matches_mode() { local target_dir="$1" validation_mode="${2:-valid}" case "$validation_mode" in valid) is_valid_restore_point_dir "$target_dir" ;; candidate) is_restore_point_candidate_dir "$target_dir" ;; *) die "Unknown restore-point validation mode: $validation_mode" ;; esac } resolve_restore_point_dir() { local requested_target="${1:-}" validation_mode="${2:-valid}" direct_target="" branch_target="" local -a matches=() if [ -z "$requested_target" ]; then find_latest_restore_point_for_current "$validation_mode" return fi if [ -d "$requested_target" ]; then direct_target="$(cd "$requested_target" && pwd -P)" if restore_point_matches_mode "$direct_target" "$validation_mode"; then printf '%s\n' "$direct_target" return fi fi branch_target="$RESTORE_POINT_ROOT/$(sanitize_ref_name "$CURRENT_BRANCH")/$requested_target" if [ -d "$branch_target" ] && restore_point_matches_mode "$branch_target" "$validation_mode"; then printf '%s\n' "$branch_target" return fi mapfile -t matches < <(list_all_restore_points | grep -E "/${requested_target}$" || true) if [ "${#matches[@]}" -eq 1 ] && restore_point_matches_mode "${matches[0]}" "$validation_mode"; then printf '%s\n' "${matches[0]}" return fi if [ "${#matches[@]}" -gt 1 ]; then die "More than one restore point matches '$requested_target'. Use the full path." fi die "Restore point '$requested_target' was not found." } create_restore_point_branch_for_label() { local label="$1" safe_branch_name safe_label timestamp branch_name safe_branch_name="$(sanitize_ref_name "$CURRENT_BRANCH")" safe_label="$(sanitize_storage_name "$label")" [ -n "$safe_label" ] || safe_label="manual" timestamp="$(date +%Y%m%d-%H%M%S)" branch_name="backup/restore-point-${safe_branch_name}-${safe_label}-${timestamp}" git branch "$branch_name" HEAD >/dev/null printf '%s\n' "$branch_name" } create_manual_restore_point() { local label="${1:-manual}" safe_branch_name safe_label timestamp restore_point_dir restore_point_repo_dir local restore_point_backup_branch current_head ensure_rsync_available safe_branch_name="$(sanitize_ref_name "$CURRENT_BRANCH")" safe_label="$(sanitize_storage_name "$label")" [ -n "$safe_label" ] || safe_label="manual" timestamp="$(date +%Y%m%d-%H%M%S)" restore_point_dir="$RESTORE_POINT_ROOT/$safe_branch_name/${timestamp}-${safe_label}" restore_point_repo_dir="$(snapshot_repo_dir "$restore_point_dir")" restore_point_backup_branch="$(create_restore_point_branch_for_label "$safe_label")" current_head="$(git rev-parse HEAD)" mkdir -p "$restore_point_repo_dir" rsync --archive --delete --exclude='.git' "$REPO_ROOT"/ "$restore_point_repo_dir"/ { printf 'branch=%s\n' "$CURRENT_BRANCH" printf 'source_branch=%s\n' "$CURRENT_BRANCH" printf 'head=%s\n' "$current_head" printf 'created_at=%s\n' "$(date +%Y-%m-%dT%H:%M:%S)" printf 'label=%s\n' "$label" printf 'backup_branch=%s\n' "$restore_point_backup_branch" printf 'reason=manual_restore_point\n' } >"$restore_point_dir/metadata.txt" save_script_managed_restore_state "$restore_point_dir" printf '%s\n' "$restore_point_dir" } ensure_no_noncherrypick_git_operation_in_progress() { local git_path for git_path in MERGE_HEAD REVERT_HEAD BISECT_LOG rebase-merge rebase-apply; 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 for git_path in CHERRY_PICK_HEAD MERGE_HEAD REVERT_HEAD BISECT_LOG rebase-merge rebase-apply; 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." } 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" } ensure_release_refs_exist() { git rev-parse --verify "$BASE_TAG" >/dev/null 2>&1 || die "Base tag '$BASE_TAG' does not exist locally. Fetch tags first." normalize_upstream_release_branch git rev-parse --verify "$CUSTOM_SOURCE_BRANCH" >/dev/null 2>&1 || die "Custom source branch '$CUSTOM_SOURCE_BRANCH' does not exist locally." git rev-parse --verify "refs/remotes/$REMOTE_NAME/$UPSTREAM_COMPARE_BRANCH" >/dev/null 2>&1 || die "Remote compare branch '$REMOTE_NAME/$UPSTREAM_COMPARE_BRANCH' was not found." } ensure_base_tag_exists() { git rev-parse --verify "$BASE_TAG" >/dev/null 2>&1 || die "Base tag '$BASE_TAG' does not exist locally. Fetch tags first." } ensure_upstream_compare_ref_exists() { git rev-parse --verify "refs/remotes/$REMOTE_NAME/$UPSTREAM_COMPARE_BRANCH" >/dev/null 2>&1 || die "Remote compare branch '$REMOTE_NAME/$UPSTREAM_COMPARE_BRANCH' was not found." } ensure_upstream_target_ref_exists() { local source_ref="" if [ -n "$UPSTREAM_RELEASE_TARGET_REF" ]; then source_ref="$(resolve_upstream_release_import_ref)" git rev-parse --verify "$source_ref" >/dev/null 2>&1 || die "Upstream release target ref '$source_ref' was not found locally." return fi normalize_upstream_release_branch source_ref="$(resolve_upstream_release_import_ref)" git rev-parse --verify "$source_ref" >/dev/null 2>&1 || die "Upstream release target ref '$source_ref' was not found locally." } capture_fetch_ref_states() { local branch ref oid seen_branches="" FETCH_REF_STATES="" for branch in "$UPSTREAM_RELEASE_BRANCH" "$UPSTREAM_COMPARE_BRANCH"; do case " $seen_branches " in *" $branch "*) continue ;; esac seen_branches="$seen_branches $branch" ref="refs/remotes/$REMOTE_NAME/$branch" if git show-ref --verify --quiet "$ref"; then oid="$(git rev-parse "$ref")" FETCH_REF_STATES="${FETCH_REF_STATES}${ref}|1|${oid}"$'\n' else FETCH_REF_STATES="${FETCH_REF_STATES}${ref}|0|"$'\n' fi done } restore_fetch_ref_states() { local ref existed oid [ -n "${STATE_FETCH_REF_STATES:-}" ] || return 0 while IFS='|' read -r ref existed oid; do [ -n "$ref" ] || continue if [ "$existed" = "1" ] && [ -n "$oid" ]; then git update-ref "$ref" "$oid" else git update-ref -d "$ref" >/dev/null 2>&1 || true fi done <<< "${STATE_FETCH_REF_STATES}" } resolve_upstream_release_import_ref() { if [ -n "$UPSTREAM_RELEASE_TARGET_REF" ]; then git rev-parse --verify "$UPSTREAM_RELEASE_TARGET_REF" >/dev/null 2>&1 || die "Configured upstream release target ref '$UPSTREAM_RELEASE_TARGET_REF' does not exist locally." printf '%s\n' "$UPSTREAM_RELEASE_TARGET_REF" return fi printf '%s/%s\n' "$REMOTE_NAME" "$UPSTREAM_RELEASE_BRANCH" } maintenance_branch_exists() { git show-ref --verify --quiet "refs/heads/$MAINTENANCE_BRANCH" } ensure_on_maintenance_branch() { if ! maintenance_branch_exists; then die "Maintenance branch '$MAINTENANCE_BRANCH' does not exist yet. Run 'bootstrap' first." fi if [ "$CURRENT_BRANCH" = "$MAINTENANCE_BRANCH" ]; then return fi if worktree_has_local_changes; then die "Current branch is '$CURRENT_BRANCH'. Switch to '$MAINTENANCE_BRANCH' with a clean worktree first." fi git switch "$MAINTENANCE_BRANCH" >/dev/null CURRENT_BRANCH="$MAINTENANCE_BRANCH" } create_or_switch_to_maintenance_branch() { MAINTENANCE_BRANCH_WAS_CREATED="0" if maintenance_branch_exists; then ensure_on_maintenance_branch return fi if [ "$CURRENT_BRANCH" != "$MAINTENANCE_BRANCH" ] && worktree_has_local_changes; then die "Create the maintenance branch from a clean worktree." fi git switch -c "$MAINTENANCE_BRANCH" "$BASE_TAG" >/dev/null MAINTENANCE_BRANCH_WAS_CREATED="1" CURRENT_BRANCH="$MAINTENANCE_BRANCH" say "Created maintenance branch '$MAINTENANCE_BRANCH' from '$BASE_TAG'." } create_backup_branch_for_reason() { local reason="$1" safe_branch_name timestamp branch_name safe_branch_name="$(sanitize_ref_name "$CURRENT_BRANCH")" timestamp="$(date +%Y%m%d-%H%M%S)" branch_name="backup/maint-${safe_branch_name}-${reason}-${timestamp}" git branch "$branch_name" HEAD >/dev/null printf '%s\n' "$branch_name" } 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/maint-${safe_branch_name}-*" | head -n 1 } list_backup_branches_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/maint-${safe_branch_name}-*" } list_snapshot_dirs_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 0 find "$snapshot_branch_dir" -mindepth 1 -maxdepth 1 -type d | sort -r } associated_backup_branch_for_snapshot() { local target_snapshot="$1" relative_path="" snapshot_branch_name="" snapshot_name="" local snapshot_timestamp="" snapshot_reason="" case "$target_snapshot" in "$SNAPSHOT_ROOT"/*) ;; *) return 1 ;; esac relative_path="${target_snapshot#$SNAPSHOT_ROOT/}" snapshot_branch_name="${relative_path%%/*}" snapshot_name="${relative_path#*/}" [ -n "$snapshot_branch_name" ] || return 1 [ -n "$snapshot_name" ] || return 1 [ "$snapshot_branch_name" != "$relative_path" ] || return 1 [ "$snapshot_name" != "$relative_path" ] || return 1 if [[ "$snapshot_name" =~ ^([0-9]{8}-[0-9]{6})-(.+)$ ]]; then snapshot_timestamp="${BASH_REMATCH[1]}" snapshot_reason="${BASH_REMATCH[2]}" printf 'backup/maint-%s-%s-%s\n' "$snapshot_branch_name" "$snapshot_reason" "$snapshot_timestamp" return 0 fi printf 'backup/maint-%s-%s\n' "$snapshot_branch_name" "$snapshot_name" } prepare_safety_before_real_action() { local backup_reason="$1" STATE_START_HEAD="$(git rev-parse HEAD)" BACKUP_BRANCH="$(create_backup_branch_for_reason "$backup_reason")" STATE_SNAPSHOT_PATH="$(create_worktree_snapshot_for_reason "$backup_reason")" say "Safety backup branch created: $BACKUP_BRANCH" say "Exact worktree snapshot created: $STATE_SNAPSHOT_PATH" } 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}-$(sanitize_ref_name "$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" } fetch_upstream_compare_ref() { say "Fetching latest compare-branch changes from $REMOTE_NAME/$UPSTREAM_COMPARE_BRANCH..." git fetch --prune --no-tags "$REMOTE_NAME" "+refs/heads/$UPSTREAM_COMPARE_BRANCH:refs/remotes/$REMOTE_NAME/$UPSTREAM_COMPARE_BRANCH" } fetch_upstream_target_ref() { local target_ref="${UPSTREAM_RELEASE_TARGET_REF:-}" if [ -z "$target_ref" ]; then say "Fetching latest release-branch changes from $REMOTE_NAME/$UPSTREAM_RELEASE_BRANCH..." git fetch --prune --no-tags "$REMOTE_NAME" "+refs/heads/$UPSTREAM_RELEASE_BRANCH:refs/remotes/$REMOTE_NAME/$UPSTREAM_RELEASE_BRANCH" return fi if git fetch --no-tags "$REMOTE_NAME" "+refs/tags/$target_ref:refs/tags/$target_ref" >/dev/null 2>&1; then say "Fetched upstream release target tag: $target_ref" return fi say "Fetching configured release target branch/ref from $REMOTE_NAME/$target_ref..." git fetch --prune --no-tags "$REMOTE_NAME" "+refs/heads/$target_ref:refs/remotes/$REMOTE_NAME/$target_ref" } fetch_upstream_refs() { fetch_upstream_compare_ref if [ "$UPSTREAM_COMPARE_BRANCH" != "$UPSTREAM_RELEASE_BRANCH" ] || [ -n "$UPSTREAM_RELEASE_TARGET_REF" ]; then fetch_upstream_target_ref fi } update_state() { 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_BASE_TAG="$BASE_TAG" STATE_UPSTREAM_RELEASE_BRANCH="$UPSTREAM_RELEASE_BRANCH" STATE_UPSTREAM_RELEASE_TARGET_REF="$UPSTREAM_RELEASE_TARGET_REF" STATE_UPSTREAM_COMPARE_BRANCH="$UPSTREAM_COMPARE_BRANCH" STATE_CUSTOM_SOURCE_BRANCH="$CUSTOM_SOURCE_BRANCH" STATE_MAINTENANCE_BRANCH="$MAINTENANCE_BRANCH" STATE_ENTRY_BRANCH="${STATE_ENTRY_BRANCH:-$CURRENT_BRANCH}" STATE_FETCH_REF_STATES="${STATE_FETCH_REF_STATES:-}" STATE_CREATED_MAINTENANCE_BRANCH="${STATE_CREATED_MAINTENANCE_BRANCH:-0}" write_state } 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 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 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, or return safely with: ./.maintain-custom-release.sh rollback" exit 1 } ordered_upstream_release_commits_for_ref() { local target_ref="$1" source_ref="" available_commit_shas="" commit source_ref="$(resolve_upstream_release_import_ref)" available_commit_shas="$(git cherry -v "$target_ref" "$source_ref" | awk '$1=="+"{print $2}')" [ -n "$available_commit_shas" ] || return 0 while IFS= read -r commit; do [ -n "$commit" ] || continue if printf '%s\n' "$available_commit_shas" | grep -Fxq "$commit"; then printf '%s\n' "$commit" fi done < <(git rev-list --reverse "${target_ref}..$source_ref") } ordered_upstream_compare_commits_for_ref() { local target_ref="$1" source_ref="$REMOTE_NAME/$UPSTREAM_COMPARE_BRANCH" available_commit_shas="" commit available_commit_shas="$(git cherry -v "$target_ref" "$source_ref" | awk '$1=="+"{print $2}')" [ -n "$available_commit_shas" ] || return 0 while IFS= read -r commit; do [ -n "$commit" ] || continue if printf '%s\n' "$available_commit_shas" | grep -Fxq "$commit"; then printf '%s\n' "$commit" fi done < <(git rev-list --reverse "${target_ref}..$source_ref") } ordered_custom_commits_not_in_ref() { local target_ref="$1" custom_commit_shas="" still_missing_shas="" commit custom_commit_shas="$(git cherry -v "$REMOTE_NAME/$UPSTREAM_COMPARE_BRANCH" "$CUSTOM_SOURCE_BRANCH" | awk '$1=="+"{print $2}')" [ -n "$custom_commit_shas" ] || return 0 still_missing_shas="$(git cherry -v "$target_ref" "$CUSTOM_SOURCE_BRANCH" | awk '$1=="+"{print $2}')" [ -n "$still_missing_shas" ] || return 0 while IFS= read -r commit; do [ -n "$commit" ] || continue if printf '%s\n' "$custom_commit_shas" | grep -Fxq "$commit" && printf '%s\n' "$still_missing_shas" | grep -Fxq "$commit"; then printf '%s\n' "$commit" fi done < <(git rev-list --reverse "$REMOTE_NAME/$UPSTREAM_COMPARE_BRANCH..$CUSTOM_SOURCE_BRANCH") } show_commit_list() { local header="$1" found_any=0 commit shift say "$header" while IFS= read -r commit; do [ -n "$commit" ] || continue found_any=1 git show --no-patch --date=short --format=' - %h | %ad | %s' "$commit" done if [ "$found_any" -eq 0 ]; then say " none" fi } run_configure() { say "Interactive configuration" BASE_TAG="$(prompt_with_default "Base tag" "$BASE_TAG")" UPSTREAM_RELEASE_BRANCH="$(prompt_with_default "Upstream release branch (for example release/v1.26)" "$UPSTREAM_RELEASE_BRANCH")" UPSTREAM_RELEASE_TARGET_REF="$(prompt_with_default_allow_clear "Upstream release target ref (example v1.26.0, use - to clear)" "$UPSTREAM_RELEASE_TARGET_REF")" MAINTENANCE_BRANCH="$(prompt_with_default "Custom maintenance branch" "$MAINTENANCE_BRANCH")" CUSTOM_SOURCE_BRANCH="$(prompt_with_default "Custom source branch" "$CUSTOM_SOURCE_BRANCH")" UPSTREAM_COMPARE_BRANCH="$(prompt_with_default "Upstream compare branch" "$UPSTREAM_COMPARE_BRANCH")" CUSTOM_TAG_SUFFIX="$(prompt_with_default "Custom tag suffix" "$CUSTOM_TAG_SUFFIX")" save_runtime_settings say "Configuration updated for the current run." } show_plan() { local plan_ref ensure_git_repo ensure_current_branch_with_state_fallback ensure_base_tag_exists ensure_upstream_compare_ref_exists ensure_upstream_target_ref_exists if maintenance_branch_exists; then plan_ref="$MAINTENANCE_BRANCH" else plan_ref="$BASE_TAG" fi say "Plan target ref: $plan_ref" show_commit_list "Upstream compare branch commits to import next:" < <(ordered_upstream_compare_commits_for_ref "$plan_ref") show_commit_list "Upstream release target commits to import next:" < <(ordered_upstream_release_commits_for_ref "$plan_ref") show_commit_list "Custom commits to import next (oldest first):" < <(ordered_custom_commits_not_in_ref "$plan_ref") } set_pending_commits_from_array() { local pending=("$@") STATE_PENDING_COMMITS="${pending[*]}" } pending_commits_to_array() { read -r -a PENDING_COMMITS_ARRAY <<< "${STATE_PENDING_COMMITS:-}" } finish_import_sequence() { STASH_HASH="${STATE_STASH_HASH:-}" STASH_REF="" restore_stash 1 STATE_PENDING_COMMITS="" STATE_ACTION_LABEL="" STATE_STATUS="completed" update_state say "Import sequence completed." 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-import stash retained for rollback at: $STATE_STASH_HASH" fi } continue_pending_sequence() { local pending=() commit pending_commits_to_array pending=("${PENDING_COMMITS_ARRAY[@]}") while [ "${#pending[@]}" -gt 0 ]; do commit="${pending[0]}" say "Cherry-picking: $(git show --no-patch --format='%h %s' "$commit")" if ! git cherry-pick -x "$commit"; then set_pending_commits_from_array "${pending[@]}" STATE_STATUS="cherry_pick_conflict" update_state say "Cherry-pick stopped because of conflicts." say "Conflicting commit: $(git show --no-patch --format='%h %s' CHERRY_PICK_HEAD)" say "Resolve conflicts and continue with: ./.maintain-custom-release.sh continue" say "Or return safely with: ./.maintain-custom-release.sh rollback" exit 1 fi pending=("${pending[@]:1}") set_pending_commits_from_array "${pending[@]}" STATE_STATUS="import_in_progress" update_state done finish_import_sequence } start_import_sequence() { local action_label="$1" shift local selected_commits=("$@") [ "${#selected_commits[@]}" -gt 0 ] || { say "No commits need to be imported for '$action_label'." return 0 } prepare_safety_before_real_action "before-${action_label}" stash_current_changes "pre-${action_label}" set_pending_commits_from_array "${selected_commits[@]}" STATE_ACTION_LABEL="$action_label" STATE_STATUS="prepared" update_state continue_pending_sequence } collect_upstream_compare_commits_for_current_branch() { ordered_upstream_compare_commits_for_ref "$CURRENT_BRANCH" } collect_upstream_target_commits_for_current_branch() { ordered_upstream_release_commits_for_ref "$CURRENT_BRANCH" } collect_custom_commits_for_current_branch() { ordered_custom_commits_not_in_ref "$CURRENT_BRANCH" } run_status() { local stash_count latest_backup latest_snapshot latest_restore_point ensure_git_repo ensure_current_branch_with_state_fallback ensure_upstream_remote stash_count="$(git stash list | wc -l | tr -d ' ')" latest_backup="$(find_latest_backup_branch_for_current || true)" latest_snapshot="$(find_latest_snapshot_for_current || true)" latest_restore_point="$(find_latest_restore_point_for_current || true)" say "Repository: $(git rev-parse --show-toplevel)" say "Current branch: $CURRENT_BRANCH" say "Maintenance branch: $MAINTENANCE_BRANCH" say "Base tag: $BASE_TAG" say "Upstream release branch: $REMOTE_NAME/$UPSTREAM_RELEASE_BRANCH" say "Upstream release target ref: ${UPSTREAM_RELEASE_TARGET_REF:-$REMOTE_NAME/$UPSTREAM_RELEASE_BRANCH}" say "Custom source branch: $CUSTOM_SOURCE_BRANCH" say "Upstream compare branch: $REMOTE_NAME/$UPSTREAM_COMPARE_BRANCH" say "Custom tag suffix: $CUSTOM_TAG_SUFFIX" say "Working tree:" if ! worktree_has_local_changes; then say " clean" else git status --short fi say "Stash entries: $stash_count" say "Latest backup for current branch: ${latest_backup:-none}" say "Latest snapshot for current branch: ${latest_snapshot:-none}" say "Latest restore point for current branch: ${latest_restore_point:-none}" if load_state; then say "Saved state: $STATE_STATUS" say "Saved backup branch: ${STATE_BACKUP_BRANCH:-none}" say "Saved starting commit: ${STATE_START_HEAD:-none}" say "Saved exact snapshot: ${STATE_SNAPSHOT_PATH:-none}" say "Saved original stash: ${STATE_STASH_HASH:-none}" say "Saved action label: ${STATE_ACTION_LABEL:-none}" say "Saved pending commits: ${STATE_PENDING_COMMITS:-none}" else say "Saved state: none" fi } run_create_restore_point() { local label="${1:-manual}" restore_point_dir="" ensure_git_repo ensure_no_git_operation_in_progress ensure_current_branch_with_state_fallback restore_point_dir="$(create_manual_restore_point "$label")" say "Manual restore point created: $restore_point_dir" say "Associated backup branch: $(snapshot_metadata_value "$restore_point_dir" backup_branch || true)" } collect_restore_point_paths() { local validation_mode="${1:-valid}" restore_point_dir if [ ! -d "$RESTORE_POINT_ROOT" ] && [ ! -d "$LEGACY_RESTORE_POINT_ROOT" ]; then return 0 fi while IFS= read -r restore_point_dir; do [ -n "$restore_point_dir" ] || continue restore_point_matches_mode "$restore_point_dir" "$validation_mode" || continue printf '%s\n' "$restore_point_dir" done < <(list_all_restore_points) } show_restore_point_entry() { local entry_number="$1" restore_point_dir="$2" validation_mode="${3:-valid}" local source_branch head_value backup_branch created_at source_branch="$(snapshot_metadata_value "$restore_point_dir" source_branch || true)" head_value="$(snapshot_metadata_value "$restore_point_dir" head || true)" backup_branch="$(snapshot_metadata_value "$restore_point_dir" backup_branch || true)" created_at="$(snapshot_metadata_value "$restore_point_dir" created_at || true)" say "$entry_number) $restore_point_dir" say " source branch: ${source_branch:-unknown}" say " head: ${head_value:-unknown}" say " created at: ${created_at:-unknown}" say " backup branch: ${backup_branch:-none}" if [ "$validation_mode" = "candidate" ] && ! is_valid_restore_point_dir "$restore_point_dir"; then say " status: orphaned" fi } run_list_restore_points() { local restore_point_dir found_any=0 entry_number=1 ensure_git_repo while IFS= read -r restore_point_dir; do [ -n "$restore_point_dir" ] || continue found_any=1 show_restore_point_entry "$entry_number" "$restore_point_dir" valid entry_number=$((entry_number + 1)) done < <(collect_restore_point_paths valid) if [ "$found_any" -eq 0 ]; then say "No valid restore points found." fi } select_restore_point_interactively() { local validation_mode="$1" action_label="$2" selection="" restore_point_dir="" local -a restore_point_dirs=() SELECTED_RESTORE_POINT="" mapfile -t restore_point_dirs < <(collect_restore_point_paths "$validation_mode") if [ "${#restore_point_dirs[@]}" -eq 0 ]; then if [ "$validation_mode" = "valid" ]; then say "No valid restore points found." else say "No restore points found." fi return 1 fi for i in "${!restore_point_dirs[@]}"; do show_restore_point_entry "$((i + 1))" "${restore_point_dirs[$i]}" "$validation_mode" done printf 'Choose backup number to %s [0 cancel]: ' "$action_label" read -r selection case "$selection" in 0|"") return 1 ;; *[!0-9]*) say "Invalid selection: $selection"; return 1 ;; esac if [ "$selection" -lt 1 ] || [ "$selection" -gt "${#restore_point_dirs[@]}" ]; then say "Invalid selection: $selection" return 1 fi restore_point_dir="${restore_point_dirs[$((selection - 1))]}" SELECTED_RESTORE_POINT="$restore_point_dir" return 0 } run_fetch_all() { ensure_git_repo ensure_no_git_operation_in_progress ensure_current_branch ensure_upstream_remote fetch_upstream_refs say "Latest upstream compare branch and release target refs fetched." } run_fetch_upstream_compare() { ensure_git_repo ensure_no_git_operation_in_progress ensure_current_branch ensure_upstream_remote fetch_upstream_compare_ref say "Latest upstream compare branch ref fetched." } run_fetch_upstream_target() { ensure_git_repo ensure_no_git_operation_in_progress ensure_current_branch ensure_upstream_remote fetch_upstream_target_ref say "Latest upstream release target ref fetched." } run_bootstrap() { local entry_branch="" ensure_git_repo ensure_no_git_operation_in_progress ensure_current_branch entry_branch="$CURRENT_BRANCH" ensure_base_tag_exists prepare_safety_before_real_action "before-bootstrap" create_or_switch_to_maintenance_branch STATE_ENTRY_BRANCH="$entry_branch" STATE_FETCH_REF_STATES="" STATE_CREATED_MAINTENANCE_BRANCH="$MAINTENANCE_BRANCH_WAS_CREATED" STATE_PENDING_COMMITS="" STATE_ACTION_LABEL="bootstrap" STATE_STATUS="bootstrap_completed" update_state say "Maintenance branch ready: $MAINTENANCE_BRANCH" say "Next step: run sync-upstream-compare, sync-upstream-target, sync-custom, or sync-all." } run_sync_upstream_compare() { local -a selected_commits=() local commit ensure_git_repo ensure_no_git_operation_in_progress ensure_current_branch ensure_upstream_remote STATE_ENTRY_BRANCH="${STATE_ENTRY_BRANCH:-$CURRENT_BRANCH}" if [ -z "${STATE_FETCH_REF_STATES:-}" ]; then capture_fetch_ref_states STATE_FETCH_REF_STATES="$FETCH_REF_STATES" fi STATE_CREATED_MAINTENANCE_BRANCH="${STATE_CREATED_MAINTENANCE_BRANCH:-0}" fetch_upstream_compare_ref ensure_upstream_compare_ref_exists ensure_on_maintenance_branch while IFS= read -r commit; do [ -n "$commit" ] || continue selected_commits+=("$commit") done < <(collect_upstream_compare_commits_for_current_branch) start_import_sequence "sync-upstream-compare" "${selected_commits[@]}" } run_sync_upstream_target() { local -a selected_commits=() local commit ensure_git_repo ensure_no_git_operation_in_progress ensure_current_branch ensure_upstream_remote STATE_ENTRY_BRANCH="${STATE_ENTRY_BRANCH:-$CURRENT_BRANCH}" if [ -z "${STATE_FETCH_REF_STATES:-}" ]; then capture_fetch_ref_states STATE_FETCH_REF_STATES="$FETCH_REF_STATES" fi STATE_CREATED_MAINTENANCE_BRANCH="${STATE_CREATED_MAINTENANCE_BRANCH:-0}" fetch_upstream_target_ref ensure_upstream_target_ref_exists ensure_on_maintenance_branch while IFS= read -r commit; do [ -n "$commit" ] || continue selected_commits+=("$commit") done < <(collect_upstream_target_commits_for_current_branch) start_import_sequence "sync-upstream-target" "${selected_commits[@]}" } run_sync_custom() { local -a selected_commits=() local commit ensure_git_repo ensure_no_git_operation_in_progress ensure_current_branch ensure_upstream_remote STATE_ENTRY_BRANCH="${STATE_ENTRY_BRANCH:-$CURRENT_BRANCH}" if [ -z "${STATE_FETCH_REF_STATES:-}" ]; then capture_fetch_ref_states STATE_FETCH_REF_STATES="$FETCH_REF_STATES" fi STATE_CREATED_MAINTENANCE_BRANCH="${STATE_CREATED_MAINTENANCE_BRANCH:-0}" fetch_upstream_compare_ref ensure_release_refs_exist ensure_on_maintenance_branch while IFS= read -r commit; do [ -n "$commit" ] || continue selected_commits+=("$commit") done < <(collect_custom_commits_for_current_branch) start_import_sequence "sync-custom" "${selected_commits[@]}" } run_sync_all() { STATE_ENTRY_BRANCH="" STATE_FETCH_REF_STATES="" STATE_CREATED_MAINTENANCE_BRANCH="0" ensure_git_repo ensure_no_git_operation_in_progress ensure_current_branch ensure_upstream_remote run_sync_upstream_compare run_sync_upstream_target run_sync_custom } run_continue() { ensure_git_repo ensure_current_branch_with_state_fallback ensure_upstream_remote load_state || die "No saved import state was found." [ "$CURRENT_BRANCH" = "${STATE_BRANCH:-$CURRENT_BRANCH}" ] || die "Saved import state belongs to branch '$STATE_BRANCH'. Check out that branch first." has_cherry_pick_in_progress || die "There is no in-progress cherry-pick to continue." BACKUP_BRANCH="${STATE_BACKUP_BRANCH:-}" STASH_HASH="${STATE_STASH_HASH:-}" STATE_STATUS="import_in_progress" update_state say "Continuing cherry-pick..." GIT_EDITOR=: git cherry-pick --continue pending_commits_to_array if [ "${#PENDING_COMMITS_ARRAY[@]}" -gt 0 ]; then set_pending_commits_from_array "${PENDING_COMMITS_ARRAY[@]:1}" update_state fi continue_pending_sequence } run_restore_saved_stash() { ensure_git_repo ensure_no_git_operation_in_progress ensure_current_branch_with_state_fallback load_state || die "No saved state was found." [ "$CURRENT_BRANCH" = "${STATE_BRANCH:-$CURRENT_BRANCH}" ] || die "Saved state belongs to branch '$STATE_BRANCH'. Check out that branch first." [ -n "${STATE_STASH_HASH:-}" ] || die "There is no saved pre-import 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_safety_before_real_action "before-restore-stash" STASH_HASH="$STATE_STASH_HASH" STASH_REF="" restore_stash 1 say "Saved pre-import local changes restored." } list_stash_entries() { git stash list --format='%gd%x09%H%x09%gs' } select_stash_interactively() { local choice stash_ref stash_hash stash_subject local -a stash_rows=() SELECTED_STASH_TARGET="" ensure_git_repo load_state || true mapfile -t stash_rows < <(list_stash_entries) if [ "${#stash_rows[@]}" -eq 0 ]; then say "No stash entries found." return 1 fi say "Stash entries:" for i in "${!stash_rows[@]}"; do IFS=$'\t' read -r stash_ref stash_hash stash_subject <<<"${stash_rows[$i]}" if [ -n "${STATE_STASH_HASH:-}" ] && [ "$stash_hash" = "$STATE_STASH_HASH" ]; then say " $((i + 1))) ${stash_ref} [saved state] - ${stash_subject}" else say " $((i + 1))) ${stash_ref} - ${stash_subject}" fi done say " B) Back" say " 0) Exit" printf 'Choose stash [Enter=Back]: ' read -r choice case "$choice" in 0) exit 0 ;; ""|[bB]) return 1 ;; *[!0-9]*) say "Invalid menu selection: $choice" return 1 ;; esac if [ "$choice" -lt 1 ] || [ "$choice" -gt "${#stash_rows[@]}" ]; then say "Invalid menu selection: $choice" return 1 fi SELECTED_STASH_TARGET="${stash_rows[$((choice - 1))]}" return 0 } run_delete_saved_stash() { local target_spec="${1:-}" stash_ref="" stash_hash="" stash_subject="" ensure_git_repo load_state || true if [ -n "$target_spec" ]; then if [[ "$target_spec" == *$'\t'* ]]; then IFS=$'\t' read -r stash_ref stash_hash stash_subject <<<"$target_spec" else stash_ref="$target_spec" stash_hash="$(git rev-parse "$stash_ref" 2>/dev/null || true)" fi else [ -n "${STATE_STASH_HASH:-}" ] || die "There is no saved pre-import stash to delete." stash_hash="$STATE_STASH_HASH" stash_ref="$(find_stash_ref_by_hash "$stash_hash" || true)" fi [ -n "$stash_ref" ] || [ -n "$stash_hash" ] || die "There is no saved pre-import stash to delete." if [ -n "$stash_hash" ]; then drop_stash_by_hash_if_present "$stash_hash" elif [ -n "$stash_ref" ]; then git stash drop "$stash_ref" >/dev/null fi if [ -n "${STATE_STASH_HASH:-}" ] && [ "$stash_hash" = "$STATE_STASH_HASH" ]; then STATE_STASH_HASH="" write_state say "Deleted the saved pre-import stash reference." else say "Deleted stash: ${stash_ref:-$stash_hash}" fi } run_rollback() { local explicit_target="${1:-}" reset_target="" reset_snapshot="" local original_restore_stash_hash="" original_restore_start_head="" local original_action_backup_branch="" original_action_snapshot="" local rollback_safety_snapshot="" rollback_safety_backup="" rollback_safety_start_head="" local preserve_stash_hash="" local restore_branch_after="" created_branch_name="" remove_created_branch="0" local needs_state_branch_switch="0" allow_entry_branch_bootstrap_rollback="0" ensure_git_repo ensure_no_noncherrypick_git_operation_in_progress load_state || true ensure_current_branch_with_state_fallback ensure_upstream_remote if [ -z "$explicit_target" ] && [ -n "${STATE_BRANCH:-}" ] && [ "$CURRENT_BRANCH" != "$STATE_BRANCH" ]; then needs_state_branch_switch="1" fi if [ -n "$explicit_target" ] && is_valid_snapshot_dir "$explicit_target"; then reset_snapshot="$explicit_target" elif [ -n "$explicit_target" ]; then git show-ref --verify --quiet "refs/heads/$explicit_target" || die "Backup branch '$explicit_target' does not exist." reset_target="$explicit_target" else if [ "$needs_state_branch_switch" = "1" ] && [ "${STATE_ACTION_LABEL:-}" = "bootstrap" ] && [ "${STATE_CREATED_MAINTENANCE_BRANCH:-0}" = "1" ] && [ -n "${STATE_ENTRY_BRANCH:-}" ] && [ "$CURRENT_BRANCH" = "$STATE_ENTRY_BRANCH" ]; then allow_entry_branch_bootstrap_rollback="1" needs_state_branch_switch="0" fi if [ "$needs_state_branch_switch" = "1" ]; then die "Saved state belongs to branch '$STATE_BRANCH'. Check out that branch or pass an explicit backup branch or snapshot." fi if [ -n "${STATE_SNAPSHOT_PATH:-}" ] && is_valid_snapshot_dir "$STATE_SNAPSHOT_PATH"; then reset_snapshot="$STATE_SNAPSHOT_PATH" fi if [ -n "${STATE_START_HEAD:-}" ]; then reset_target="$STATE_START_HEAD" elif [ -n "${STATE_BACKUP_BRANCH:-}" ]; then reset_target="$STATE_BACKUP_BRANCH" else reset_target="$(find_latest_backup_branch_for_current || true)" fi fi if [ -z "$reset_target" ] && [ -n "$reset_snapshot" ]; then reset_target="$(snapshot_metadata_value "$reset_snapshot" head || true)" fi [ -n "$reset_target" ] || [ -n "$reset_snapshot" ] || die "No saved rollback target was found." original_restore_stash_hash="${STATE_STASH_HASH:-}" original_restore_start_head="${STATE_START_HEAD:-}" original_action_backup_branch="${STATE_BACKUP_BRANCH:-}" original_action_snapshot="${STATE_SNAPSHOT_PATH:-}" restore_branch_after="${STATE_ENTRY_BRANCH:-}" created_branch_name="${STATE_BRANCH:-$MAINTENANCE_BRANCH}" remove_created_branch="${STATE_CREATED_MAINTENANCE_BRANCH:-0}" if [ -z "$original_restore_start_head" ] && [ -n "$reset_snapshot" ]; then original_restore_start_head="$(snapshot_metadata_value "$reset_snapshot" head || true)" fi if has_cherry_pick_in_progress; then say "Aborting in-progress cherry-pick before rollback..." git cherry-pick --abort || die "Failed to abort the in-progress cherry-pick." 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_safety_before_real_action "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 [ "$needs_state_branch_switch" = "1" ]; then if git show-ref --verify --quiet "refs/heads/$STATE_BRANCH"; then git switch "$STATE_BRANCH" >/dev/null CURRENT_BRANCH="$STATE_BRANCH" else die "Saved state branch '$STATE_BRANCH' does not exist anymore." fi 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-import 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 if [ "$allow_entry_branch_bootstrap_rollback" = "1" ]; then restore_branch_after="${STATE_ENTRY_BRANCH:-$restore_branch_after}" fi restore_fetch_ref_states if [ -n "$restore_branch_after" ] && [ "$CURRENT_BRANCH" != "$restore_branch_after" ] && ! worktree_has_local_changes; then git switch "$restore_branch_after" >/dev/null CURRENT_BRANCH="$restore_branch_after" fi if [ "$remove_created_branch" = "1" ] && [ -n "$created_branch_name" ] && git show-ref --verify --quiet "refs/heads/$created_branch_name"; then git branch -D "$created_branch_name" >/dev/null say "Removed created maintenance branch: $created_branch_name" fi say "Rollback completed." say "Restored starting commit: ${original_restore_start_head:-$reset_target}" if [ -n "$reset_snapshot" ]; then say "Restored snapshot: $reset_snapshot" fi if [ -n "$preserve_stash_hash" ]; then STATE_BRANCH="$CURRENT_BRANCH" STATE_ENTRY_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_BASE_TAG="$BASE_TAG" STATE_UPSTREAM_RELEASE_BRANCH="$UPSTREAM_RELEASE_BRANCH" STATE_UPSTREAM_RELEASE_TARGET_REF="$UPSTREAM_RELEASE_TARGET_REF" STATE_UPSTREAM_COMPARE_BRANCH="$UPSTREAM_COMPARE_BRANCH" STATE_CUSTOM_SOURCE_BRANCH="$CUSTOM_SOURCE_BRANCH" STATE_MAINTENANCE_BRANCH="$MAINTENANCE_BRANCH" STATE_START_HEAD="$rollback_safety_start_head" STATE_SNAPSHOT_PATH="$rollback_safety_snapshot" STATE_PENDING_COMMITS="" STATE_ACTION_LABEL="" STATE_STATUS="rollback_completed" STATE_FETCH_REF_STATES="" STATE_CREATED_MAINTENANCE_BRANCH="0" write_state 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" say "Safety stash of the pre-rollback local changes kept at: $preserve_stash_hash" else if [ -z "$explicit_target" ]; then cleanup_temporary_restore_artifacts "$original_action_backup_branch" "$original_action_snapshot" fi cleanup_temporary_restore_artifacts "$rollback_safety_backup" "$rollback_safety_snapshot" rm -f "$STATE_FILE" say "Temporary rollback safety artifacts were cleaned up." fi } run_restore_snapshot() { local target_snapshot="${1:-}" restore_safety_backup="" restore_safety_snapshot="" local restore_safety_start_head="" restore_start_head="" preserve_stash_hash="" ensure_git_repo ensure_no_noncherrypick_git_operation_in_progress load_state || die "No saved 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:-}" if [ -z "$restore_start_head" ]; then restore_start_head="$(snapshot_metadata_value "$target_snapshot" head || true)" fi [ -n "$restore_start_head" ] || die "No saved starting commit was found for the snapshot restore." if has_cherry_pick_in_progress; then say "Aborting in-progress cherry-pick before snapshot restore..." git cherry-pick --abort || die "Failed to abort the in-progress cherry-pick." 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_safety_before_real_action "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-import local changes..." restore_stash 1 fi say "Restoring exact worktree snapshot from: $target_snapshot" restore_worktree_snapshot "$target_snapshot" say "Snapshot restore completed." if [ -n "$preserve_stash_hash" ]; then 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_BASE_TAG="$BASE_TAG" STATE_UPSTREAM_RELEASE_BRANCH="$UPSTREAM_RELEASE_BRANCH" STATE_UPSTREAM_RELEASE_TARGET_REF="$UPSTREAM_RELEASE_TARGET_REF" STATE_UPSTREAM_COMPARE_BRANCH="$UPSTREAM_COMPARE_BRANCH" STATE_CUSTOM_SOURCE_BRANCH="$CUSTOM_SOURCE_BRANCH" STATE_MAINTENANCE_BRANCH="$MAINTENANCE_BRANCH" STATE_START_HEAD="$restore_safety_start_head" STATE_SNAPSHOT_PATH="$restore_safety_snapshot" STATE_PENDING_COMMITS="" STATE_ACTION_LABEL="" STATE_STATUS="snapshot_restored" write_state 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" say "Safety stash of the pre-restore local changes kept at: $preserve_stash_hash" else cleanup_temporary_restore_artifacts "$restore_safety_backup" "$restore_safety_snapshot" rm -f "$STATE_FILE" say "Temporary snapshot-restore safety artifacts were cleaned up." fi } run_delete_snapshot() { local target_snapshot="${1:-}" local associated_backup_branch="" ensure_git_repo load_state || true if [ -z "$target_snapshot" ]; then target_snapshot="${STATE_SNAPSHOT_PATH:-}" fi [ -n "$target_snapshot" ] || die "There is no saved snapshot to delete." is_valid_snapshot_dir "$target_snapshot" || die "Snapshot '$target_snapshot' is not valid." rm -rf "$target_snapshot" associated_backup_branch="$(associated_backup_branch_for_snapshot "$target_snapshot" || true)" if [ -n "$associated_backup_branch" ] && git show-ref --verify --quiet "refs/heads/$associated_backup_branch"; then git branch -D "$associated_backup_branch" >/dev/null say "Deleted associated backup branch: $associated_backup_branch" fi if [ "${STATE_SNAPSHOT_PATH:-}" = "$target_snapshot" ]; then STATE_SNAPSHOT_PATH="" write_state fi say "Deleted snapshot: $target_snapshot" } select_snapshot_interactively() { local choice target_snapshot local -a snapshot_dirs=() SELECTED_SNAPSHOT_TARGET="" ensure_git_repo load_state || true ensure_current_branch_with_state_fallback mapfile -t snapshot_dirs < <(list_snapshot_dirs_for_current) if [ "${#snapshot_dirs[@]}" -eq 0 ]; then say "No snapshots found." return 1 fi say "Snapshots:" for i in "${!snapshot_dirs[@]}"; do target_snapshot="${snapshot_dirs[$i]}" if [ -n "${STATE_SNAPSHOT_PATH:-}" ] && [ "$target_snapshot" = "$STATE_SNAPSHOT_PATH" ]; then say " $((i + 1))) $(basename "$target_snapshot") [saved state]" else say " $((i + 1))) $(basename "$target_snapshot")" fi done say " B) Back" say " 0) Exit" printf 'Choose snapshot [Enter=Back]: ' read -r choice case "$choice" in 0) exit 0 ;; ""|[bB]) return 1 ;; *[!0-9]*) say "Invalid menu selection: $choice" return 1 ;; esac if [ "$choice" -lt 1 ] || [ "$choice" -gt "${#snapshot_dirs[@]}" ]; then say "Invalid menu selection: $choice" return 1 fi SELECTED_SNAPSHOT_TARGET="${snapshot_dirs[$((choice - 1))]}" return 0 } run_restore_restore_point() { local requested_target="${1:-}" restore_point_dir="" local restore_source_branch="" restore_head="" restore_backup_branch="" local restore_safety_backup="" restore_safety_snapshot="" restore_safety_start_head="" local preserve_stash_hash="" ensure_git_repo ensure_no_noncherrypick_git_operation_in_progress ensure_current_branch_with_state_fallback restore_point_dir="$(resolve_restore_point_dir "$requested_target")" [ -n "$restore_point_dir" ] || die "No restore point was found." restore_source_branch="$(snapshot_metadata_value "$restore_point_dir" source_branch || true)" restore_head="$(snapshot_metadata_value "$restore_point_dir" head || true)" restore_backup_branch="$(snapshot_metadata_value "$restore_point_dir" backup_branch || true)" [ -n "$restore_head" ] || die "Restore point '$restore_point_dir' does not include a saved HEAD." if has_cherry_pick_in_progress; then say "Aborting in-progress cherry-pick before restore point restore..." git cherry-pick --abort || die "Failed to abort the in-progress cherry-pick." 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 restore point." fi prepare_safety_before_real_action "before-restore-point" 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-point-preserve" preserve_stash_hash="$STASH_HASH" fi if [ -n "$restore_source_branch" ] && [ "$CURRENT_BRANCH" != "$restore_source_branch" ]; then if git show-ref --verify --quiet "refs/heads/$restore_source_branch"; then git switch "$restore_source_branch" >/dev/null else git switch -c "$restore_source_branch" "$restore_head" >/dev/null fi CURRENT_BRANCH="$restore_source_branch" fi say "Resetting '$CURRENT_BRANCH' to '$restore_head' before restore point restore..." git reset --hard "$restore_head" >/dev/null say "Restoring exact worktree from restore point: $restore_point_dir" restore_worktree_snapshot "$restore_point_dir" restore_script_managed_refs "$restore_point_dir" restore_script_managed_snapshots "$restore_point_dir" restore_script_managed_state_file "$restore_point_dir" say "Restore point restored: $restore_point_dir" say "Original restore-point backup branch: ${restore_backup_branch:-none}" if [ -n "$preserve_stash_hash" ]; then 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_BASE_TAG="$BASE_TAG" STATE_UPSTREAM_RELEASE_BRANCH="$UPSTREAM_RELEASE_BRANCH" STATE_UPSTREAM_RELEASE_TARGET_REF="$UPSTREAM_RELEASE_TARGET_REF" STATE_UPSTREAM_COMPARE_BRANCH="$UPSTREAM_COMPARE_BRANCH" STATE_CUSTOM_SOURCE_BRANCH="$CUSTOM_SOURCE_BRANCH" STATE_MAINTENANCE_BRANCH="$MAINTENANCE_BRANCH" STATE_START_HEAD="$restore_safety_start_head" STATE_SNAPSHOT_PATH="$restore_safety_snapshot" STATE_PENDING_COMMITS="" STATE_ACTION_LABEL="restore-point" STATE_STATUS="restore_point_restored" write_state 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" say "Safety stash of the pre-restore local changes kept at: $preserve_stash_hash" else cleanup_temporary_restore_artifacts "$restore_safety_backup" "$restore_safety_snapshot" say "Temporary restore-point safety artifacts were cleaned up." fi } run_delete_restore_point() { local requested_target="${1:-}" restore_point_dir="" restore_point_backup_branch="" restore_point_parent="" [ -n "$requested_target" ] || die "Provide the restore point path or name to delete." ensure_git_repo restore_point_dir="$(resolve_restore_point_dir "$requested_target" candidate)" restore_point_backup_branch="$(snapshot_metadata_value "$restore_point_dir" backup_branch || true)" restore_point_parent="$(dirname "$restore_point_dir")" rm -rf "$restore_point_dir" if [ -n "$restore_point_backup_branch" ] && git show-ref --verify --quiet "refs/heads/$restore_point_backup_branch"; then git branch -D "$restore_point_backup_branch" >/dev/null fi rmdir "$restore_point_parent" >/dev/null 2>&1 || true say "Deleted restore point: $restore_point_dir" if [ -n "$restore_point_backup_branch" ]; then say "Deleted associated backup branch: $restore_point_backup_branch" fi } run_tag_release() { local upstream_version_tag="${1:-}" custom_tag="" [ -n "$upstream_version_tag" ] || die "Provide the upstream release tag, for example: v1.26.0" ensure_git_repo ensure_no_git_operation_in_progress ensure_current_branch ensure_upstream_remote ensure_on_maintenance_branch custom_tag="${upstream_version_tag}${CUSTOM_TAG_SUFFIX}" git rev-parse --verify "$custom_tag" >/dev/null 2>&1 && die "Tag '$custom_tag' already exists." git tag -a "$custom_tag" -m "Custom release ${custom_tag} from ${MAINTENANCE_BRANCH}" say "Created custom tag: $custom_tag" } show_backups_menu() { local choice restore_point_label restore_point_target delete_restore_point_target while true; do cat < B) Back 0) Exit EOF printf 'Choose an action [Enter=Back]: ' read -r choice case "$choice" in 1) run_restore_saved_stash; return ;; 2) printf 'Optional snapshot path (leave empty for saved state): ' read -r snapshot_target run_restore_snapshot "$snapshot_target" return ;; 3) show_delete_restore_menu ;; ""|[bB]) return ;; 0) exit 0 ;; *) say "Invalid menu selection: $choice" ;; esac done } show_delete_restore_menu() { local choice snapshot_target="" while true; do cat < 2) Delete saved exact snapshot > 3) Delete exact snapshot by path B) Back 0) Exit EOF printf 'Choose an action [Enter=Back]: ' read -r choice case "$choice" in 1) select_stash_interactively || continue run_delete_saved_stash "$SELECTED_STASH_TARGET" ;; 2) select_snapshot_interactively || continue run_delete_snapshot "$SELECTED_SNAPSHOT_TARGET" ;; 3) printf 'Snapshot path to delete: ' read -r snapshot_target [ -n "$snapshot_target" ] || continue run_delete_snapshot "$snapshot_target" ;; ""|[bB]) return ;; 0) exit 0 ;; *) say "Invalid menu selection: $choice" ;; esac done } show_rollback_menu() { local choice current_target target local -a rollback_targets=() local -a rollback_labels=() ensure_git_repo load_state || true ensure_current_branch_with_state_fallback if [ -n "${STATE_START_HEAD:-}" ] || [ -n "${STATE_BACKUP_BRANCH:-}" ] || { [ -n "${STATE_SNAPSHOT_PATH:-}" ] && is_valid_snapshot_dir "$STATE_SNAPSHOT_PATH"; }; then rollback_targets+=("__SAVED_STATE__") rollback_labels+=("Saved rollback state") fi if [ -n "${STATE_BACKUP_BRANCH:-}" ] && git show-ref --verify --quiet "refs/heads/$STATE_BACKUP_BRANCH"; then rollback_targets+=("$STATE_BACKUP_BRANCH") rollback_labels+=("Saved backup branch [$STATE_BACKUP_BRANCH]") fi if [ -n "${STATE_SNAPSHOT_PATH:-}" ] && is_valid_snapshot_dir "$STATE_SNAPSHOT_PATH"; then rollback_targets+=("$STATE_SNAPSHOT_PATH") rollback_labels+=("Saved snapshot [$(basename "$STATE_SNAPSHOT_PATH")]") fi while IFS= read -r target; do [ -n "$target" ] || continue case " ${rollback_targets[*]} " in *" $target "*) continue ;; esac rollback_targets+=("$target") rollback_labels+=("Backup branch [$target]") done < <(list_backup_branches_for_current) while IFS= read -r target; do [ -n "$target" ] || continue case " ${rollback_targets[*]} " in *" $target "*) continue ;; esac rollback_targets+=("$target") rollback_labels+=("Snapshot [$(basename "$target")]") done < <(list_snapshot_dirs_for_current) if [ "${#rollback_targets[@]}" -eq 0 ]; then say "No rollback targets found." return fi while true; do say "Rollback:" for i in "${!rollback_targets[@]}"; do say " $((i + 1))) ${rollback_labels[$i]}" done say " B) Back" say " 0) Exit" printf 'Choose an action [Enter=Back]: ' read -r choice case "$choice" in 0) exit 0 ;; ""|[bB]) return ;; *[!0-9]*) say "Invalid menu selection: $choice" continue ;; esac if [ "$choice" -ge 1 ] && [ "$choice" -le "${#rollback_targets[@]}" ]; then current_target="${rollback_targets[$((choice - 1))]}" if [ "$current_target" = "__SAVED_STATE__" ]; then run_rollback "" else run_rollback "$current_target" fi return fi say "Invalid menu selection: $choice" done } show_menu() { local choice tag_value while true; do cat < 3) Show status 4) Bootstrap maintenance branch 5) Fetch upstreams > 6) Show import plan 7) Sync > 8) Continue interrupted import 9) Rollback > 10) Restore > 11) Create custom release tag 12) Help 0) Exit EOF printf 'Choose an action: ' read -r choice case "$choice" in 1) run_configure ;; 2) show_backups_menu ;; 3) run_status ;; 4) run_bootstrap ;; 5) show_fetch_upstreams_menu ;; 6) show_plan ;; 7) show_sync_menu ;; 8) run_continue; return ;; 9) show_rollback_menu ;; 10) show_restore_menu ;; 11) printf 'Enter the upstream version tag to mark, for example v1.26.0: ' read -r tag_value run_tag_release "$tag_value" return ;; 12) show_usage ;; 0) exit 0 ;; *) say "Invalid menu selection: $choice" ;; esac done } main() { self_update_external_copy_if_needed "$@" load_runtime_settings case "${1:-menu}" in configure) run_configure ;; create-restore-point) run_create_restore_point "${2:-manual}" ;; list-restore-points) run_list_restore_points ;; restore-point|restore-restore-point) run_restore_restore_point "${2:-}" ;; delete-restore-point) run_delete_restore_point "${2:-}" ;; status) run_status ;; fetch) run_fetch_all ;; fetch-upstream-compare) run_fetch_upstream_compare ;; fetch-upstream-target) run_fetch_upstream_target ;; plan) show_plan ;; bootstrap) run_bootstrap ;; sync-upstream|sync-upstream-target) run_sync_upstream_target ;; sync-upstream-compare) run_sync_upstream_compare ;; sync-custom) run_sync_custom ;; sync-all) run_sync_all ;; continue) run_continue ;; rollback) run_rollback "${2:-}" ;; restore-stash) run_restore_saved_stash ;; restore-snapshot) run_restore_snapshot "${2:-}" ;; tag) run_tag_release "${2:-}" ;; help|-h|--help) show_usage ;; menu) if [ -t 0 ] && [ -t 1 ]; then show_menu else show_usage fi ;; *) die "Unknown command: $1. Run with 'help' to see available options." ;; esac } main "$@"