1420 lines
48 KiB
Bash
Executable File
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 "$@"
|