Files
gitea/.maintain-custom-release.sh
T
petru 363c86ecaf
release-nightly / nightly-binary (push) Has been cancelled
release-nightly / nightly-container (push) Has been cancelled
Modified - Extended the custom-release maintenance helper with manual restore points and corrected its fetch behavior.
2026-05-13 20:26:59 +00:00

1420 lines
48 KiB
Bash
Executable File

#!/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}"
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}"
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_BACKUP_BRANCH=""
STATE_STASH_HASH=""
STATE_REMOTE_NAME=""
STATE_REMOTE_URL=""
STATE_BASE_TAG=""
STATE_UPSTREAM_RELEASE_BRANCH=""
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_UPDATED_AT=""
say() {
printf '%s\n' "$*"
}
die() {
say "ERROR: $*" >&2
exit 1
}
show_usage() {
cat <<EOF
Usage: $(basename "$0") [command]
Environment defaults:
BASE_TAG=$BASE_TAG
UPSTREAM_RELEASE_BRANCH=$UPSTREAM_RELEASE_BRANCH
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 <restore-point-dir-or-name>
Delete a manual restore point and its associated backup branch.
status
Show the current config, branch state, and saved recovery metadata.
fetch
Fetch the configured upstream refs only.
plan
Show which upstream release commits and which custom commits would be imported next.
bootstrap
Create the maintenance branch from BASE_TAG if needed, then import missing upstream and custom commits.
sync-upstream
Import only the missing commits from upstream/\$UPSTREAM_RELEASE_BRANCH.
sync-custom
Import only your custom commits from \$CUSTOM_SOURCE_BRANCH.
sync-all
Import missing upstream release commits and then 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 <upstream-version-tag>
Create an annotated custom release tag like <tag>\$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.
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. Run: ./.maintain-custom-release.sh plan
4. First release on a new series:
- run: ./.maintain-custom-release.sh bootstrap
5. Later patch releases on the same series:
- keep the same upstream minor release branch, for example release/v1.26
- run: ./.maintain-custom-release.sh sync-all
6. 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
7. If you want to return to an explicitly saved manual checkpoint:
- run: ./.maintain-custom-release.sh restore-point
8. 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"
read -r reply
if [ -n "$reply" ]; then
printf '%s\n' "$reply"
else
printf '%s\n' "$current_value"
fi
}
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"
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_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_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_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"
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
}
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 restore_point_dir source_branch
while IFS= read -r restore_point_dir; do
[ -n "$restore_point_dir" ] || 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_valid_restore_point_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"
}
resolve_restore_point_dir() {
local requested_target="${1:-}" direct_target="" branch_target=""
local -a matches=()
if [ -z "$requested_target" ]; then
find_latest_restore_point_for_current
return
fi
if [ -d "$requested_target" ]; then
direct_target="$(cd "$requested_target" && pwd -P)"
if is_valid_restore_point_dir "$direct_target"; 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" ] && is_valid_restore_point_dir "$branch_target"; then
printf '%s\n' "$branch_target"
return
fi
mapfile -t matches < <(list_all_restore_points | grep -E "/${requested_target}$" || true)
if [ "${#matches[@]}" -eq 1 ] && is_valid_restore_point_dir "${matches[0]}"; 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"
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."
}
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() {
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
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
}
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_refs() {
local -a fetch_args
say "Fetching latest changes from $REMOTE_NAME..."
fetch_args=(--prune --no-tags "$REMOTE_NAME" "+refs/heads/$UPSTREAM_RELEASE_BRANCH:refs/remotes/$REMOTE_NAME/$UPSTREAM_RELEASE_BRANCH")
if [ "$UPSTREAM_COMPARE_BRANCH" != "$UPSTREAM_RELEASE_BRANCH" ]; then
fetch_args+=("+refs/heads/$UPSTREAM_COMPARE_BRANCH:refs/remotes/$REMOTE_NAME/$UPSTREAM_COMPARE_BRANCH")
fi
git fetch "${fetch_args[@]}"
}
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_COMPARE_BRANCH="$UPSTREAM_COMPARE_BRANCH"
STATE_CUSTOM_SOURCE_BRANCH="$CUSTOM_SOURCE_BRANCH"
STATE_MAINTENANCE_BRANCH="$MAINTENANCE_BRANCH"
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" available_commit_shas="" commit
available_commit_shas="$(git cherry -v "$target_ref" "$REMOTE_NAME/$UPSTREAM_RELEASE_BRANCH" | 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}..$REMOTE_NAME/$UPSTREAM_RELEASE_BRANCH")
}
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")"
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")"
say "Configuration updated for the current run."
}
show_plan() {
local plan_ref
ensure_git_repo
ensure_current_branch_with_state_fallback
ensure_upstream_remote
fetch_upstream_refs
ensure_release_refs_exist
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 release commits to import next:" < <(ordered_upstream_release_commits_for_ref "$plan_ref")
show_commit_list "Custom commits to import next:" < <(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_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 "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)"
}
run_list_restore_points() {
local restore_point_dir source_branch head_value backup_branch created_at found_any=0
ensure_git_repo
if [ ! -d "$RESTORE_POINT_ROOT" ] && [ ! -d "$LEGACY_RESTORE_POINT_ROOT" ]; then
say "No restore points found."
return
fi
while IFS= read -r restore_point_dir; do
[ -n "$restore_point_dir" ] || continue
found_any=1
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 "- $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}"
done < <(list_all_restore_points)
if [ "$found_any" -eq 0 ]; then
say "No restore points found."
fi
}
run_fetch_only() {
ensure_git_repo
ensure_no_git_operation_in_progress
ensure_current_branch
ensure_upstream_remote
fetch_upstream_refs
ensure_release_refs_exist
say "Latest upstream refs fetched."
}
run_bootstrap() {
ensure_git_repo
ensure_no_git_operation_in_progress
ensure_current_branch
ensure_upstream_remote
fetch_upstream_refs
ensure_release_refs_exist
create_or_switch_to_maintenance_branch
run_sync_all
}
run_sync_upstream() {
local -a selected_commits=()
local commit
ensure_git_repo
ensure_no_git_operation_in_progress
ensure_current_branch
ensure_upstream_remote
fetch_upstream_refs
ensure_release_refs_exist
ensure_on_maintenance_branch
while IFS= read -r commit; do
[ -n "$commit" ] || continue
selected_commits+=("$commit")
done < <(collect_upstream_commits_for_current_branch)
start_import_sequence "sync-upstream" "${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
fetch_upstream_refs
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() {
ensure_git_repo
ensure_no_git_operation_in_progress
ensure_current_branch
ensure_upstream_remote
fetch_upstream_refs
ensure_release_refs_exist
ensure_on_maintenance_branch
run_sync_upstream
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."
}
run_rollback() {
local explicit_target="${1:-}" reset_target="" reset_snapshot=""
local original_restore_stash_hash="" original_restore_start_head=""
local rollback_safety_snapshot="" rollback_safety_backup="" rollback_safety_start_head=""
local preserve_stash_hash=""
ensure_git_repo
ensure_no_noncherrypick_git_operation_in_progress
load_state || true
ensure_current_branch_with_state_fallback
ensure_upstream_remote
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 [ -n "${STATE_BRANCH:-}" ] && [ "$CURRENT_BRANCH" != "$STATE_BRANCH" ]; 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:-}"
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 [ -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
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_BASE_TAG="$BASE_TAG"
STATE_UPSTREAM_RELEASE_BRANCH="$UPSTREAM_RELEASE_BRANCH"
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"
write_state
say "Rollback completed."
say "Restored starting commit: ${original_restore_start_head:-$reset_target}"
if [ -n "$reset_snapshot" ]; then
say "Restored snapshot: $reset_snapshot"
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 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"
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_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 "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_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"
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_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 "Restore point restored: $restore_point_dir"
say "Original restore-point backup branch: ${restore_backup_branch:-none}"
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_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")"
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_menu() {
local choice snapshot_target rollback_target tag_value restore_point_label restore_point_target delete_restore_point_target
while true; do
cat <<EOF
Available actions:
1) Edit configuration
2) Create manual restore point
3) List manual restore points
4) Restore manual restore point
5) Delete manual restore point
6) Show status
7) Fetch upstream refs
8) Show import plan
9) Bootstrap maintenance branch
10) Sync upstream release commits
11) Sync custom commits
12) Sync all
13) Continue interrupted import
14) Rollback
15) Restore saved stash
16) Restore exact snapshot
17) Create custom release tag
18) Help
0) Exit
EOF
printf 'Choose an action: '
read -r choice
case "$choice" in
1) run_configure ;;
2)
printf 'Optional restore point label [manual]: '
read -r restore_point_label
run_create_restore_point "${restore_point_label:-manual}"
;;
3) run_list_restore_points ;;
4)
printf 'Optional restore point path or name (leave empty for latest on current branch): '
read -r restore_point_target
run_restore_restore_point "$restore_point_target"
return
;;
5)
printf 'Enter the restore point path or name to delete: '
read -r delete_restore_point_target
run_delete_restore_point "$delete_restore_point_target"
;;
6) run_status ;;
7) run_fetch_only ;;
8) show_plan ;;
9) run_bootstrap; return ;;
10) run_sync_upstream; return ;;
11) run_sync_custom; return ;;
12) run_sync_all; return ;;
13) run_continue; return ;;
14)
printf 'Optional backup branch or snapshot path (leave empty for saved state): '
read -r rollback_target
run_rollback "$rollback_target"
return
;;
15) run_restore_saved_stash; return ;;
16)
printf 'Optional snapshot path (leave empty for saved state): '
read -r snapshot_target
run_restore_snapshot "$snapshot_target"
return
;;
17)
printf 'Enter the upstream version tag to mark, for example v1.26.0: '
read -r tag_value
run_tag_release "$tag_value"
return
;;
18) show_usage ;;
0) exit 0 ;;
*) say "Invalid menu selection: $choice" ;;
esac
done
}
main() {
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_only ;;
plan) show_plan ;;
bootstrap) run_bootstrap ;;
sync-upstream) run_sync_upstream ;;
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 "$@"