Added - Finalized the safe upstream update helper script as .update-gitea.sh.
- 1 - I renamed the updater to `.update-gitea.sh`, documented its purpose and ordered usage steps, and added an interactive menu plus direct commands for `sync`, `dry-run`, `fetch`, `backup`, `status`, `list-backups`, `rollback`, `restore-stash`, and `clean-dry-run`. - 2 - I hardened real update actions so they verify repository state, enforce or offer safety backups before modifying the branch, stash tracked and untracked local changes, record sync state inside `.git`, and support rollback to the exact saved starting commit or to a selected backup branch while preserving recoverability of local changes. - 3 - I implemented a reusable dry-run workspace under `.git/.update-gitea-dry-run`, added cleanup for both that workspace and legacy temporary dry-run directories, and made dry-run collect and report all detected rebase conflict steps without modifying the real repository. - 4 - I improved conflict reporting so package.json conflicts show the upstream scripts block, the conflicting commit side, and the current branch working-tree side, and the final dry-run summary now includes the total conflict-step count, the local commits that conflicted, and the unique conflicted files.
This commit is contained in:
+755
-19
@@ -12,12 +12,31 @@ set -euo pipefail
|
||||
# 5. fetches the latest upstream changes and rebases the current branch on top
|
||||
# of upstream/main;
|
||||
# 6. reapplies the local stash only after a successful rebase.
|
||||
# It also offers a dry-run mode that performs the same verification flow in a
|
||||
# temporary clone, so the current repository is left untouched.
|
||||
# If a conflict happens, the backup branch and the stash are both kept so the
|
||||
# local work can be recovered manually.
|
||||
|
||||
BRANCH="${BRANCH:-main}"
|
||||
REMOTE_NAME="${REMOTE_NAME:-upstream}"
|
||||
REMOTE_URL="${REMOTE_URL:-https://github.com/go-gitea/gitea.git}"
|
||||
REPO_ROOT=""
|
||||
GIT_DIR=""
|
||||
STATE_FILE=""
|
||||
DRY_RUN_ROOT=""
|
||||
CURRENT_BRANCH=""
|
||||
BACKUP_BRANCH=""
|
||||
STASH_REF=""
|
||||
STASH_HASH=""
|
||||
STATE_BRANCH=""
|
||||
STATE_BACKUP_BRANCH=""
|
||||
STATE_STASH_HASH=""
|
||||
STATE_REMOTE_NAME=""
|
||||
STATE_REMOTE_URL=""
|
||||
STATE_TARGET_BRANCH=""
|
||||
STATE_START_HEAD=""
|
||||
STATE_STATUS=""
|
||||
STATE_UPDATED_AT=""
|
||||
|
||||
say() {
|
||||
printf '%s\n' "$*"
|
||||
@@ -28,12 +47,116 @@ die() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
show_usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [command]
|
||||
|
||||
Available commands:
|
||||
sync Run the full safe update flow: backup, stash, fetch, rebase, restore.
|
||||
dry-run Run a dry-run verification in a dedicated reusable clone without changing this repository, collecting all detected rebase conflicts.
|
||||
fetch Fetch the latest upstream changes only.
|
||||
backup Create a safety backup branch from the current HEAD only.
|
||||
list-backups
|
||||
List available backup branches created by this script.
|
||||
rollback [backup-branch]
|
||||
Return the current branch to a saved backup branch and restore saved local changes when possible.
|
||||
restore-stash
|
||||
Re-apply the saved pre-sync local stash from the last recorded sync state.
|
||||
clean-dry-run
|
||||
Remove the reusable dry-run workspace and legacy temporary dry-run clones.
|
||||
status Show the current repository, branch, remote, and local-change status.
|
||||
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: ./.update-gitea.sh status
|
||||
2. Run: ./.update-gitea.sh dry-run
|
||||
3. If dry-run is clean, run: ./.update-gitea.sh sync
|
||||
4. If rebase stops with conflicts:
|
||||
- either resolve them and continue with: git rebase --continue
|
||||
- or return safely with: ./.update-gitea.sh rollback
|
||||
5. If restoring local changes after the rebase causes conflicts:
|
||||
- either resolve them manually
|
||||
- or return safely with: ./.update-gitea.sh rollback
|
||||
6. Use: ./.update-gitea.sh list-backups
|
||||
to see the restore points created by the script.
|
||||
7. Use: ./.update-gitea.sh restore-stash
|
||||
only when you intentionally aborted a failed sync and want the saved local
|
||||
changes reapplied again.
|
||||
EOF
|
||||
}
|
||||
|
||||
ensure_git_repo() {
|
||||
git rev-parse --show-toplevel >/dev/null 2>&1 || die "This script must be run inside a git repository."
|
||||
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/.update-gitea-state"
|
||||
DRY_RUN_ROOT="$GIT_DIR/.update-gitea-dry-run"
|
||||
}
|
||||
|
||||
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_TARGET_BRANCH=%q\n' "$STATE_TARGET_BRANCH"
|
||||
printf 'STATE_START_HEAD=%q\n' "$STATE_START_HEAD"
|
||||
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
|
||||
}
|
||||
|
||||
clear_state() {
|
||||
ensure_git_repo
|
||||
rm -f "$STATE_FILE"
|
||||
}
|
||||
|
||||
worktree_has_local_changes() {
|
||||
! git diff --quiet || ! git diff --cached --quiet || [ -n "$(git ls-files --others --exclude-standard)" ]
|
||||
}
|
||||
|
||||
has_rebase_in_progress() {
|
||||
[ -e "$(git rev-parse --git-path rebase-merge)" ] || [ -e "$(git rev-parse --git-path rebase-apply)" ]
|
||||
}
|
||||
|
||||
has_rebase_in_progress_in_repo() {
|
||||
local repo_path="$1"
|
||||
[ -d "$repo_path/.git/rebase-merge" ] || [ -d "$repo_path/.git/rebase-apply" ]
|
||||
}
|
||||
|
||||
sanitize_ref_name() {
|
||||
printf '%s\n' "${1//\//-}"
|
||||
}
|
||||
|
||||
ensure_no_nonrebase_git_operation_in_progress() {
|
||||
local git_path
|
||||
for git_path in MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD BISECT_LOG; do
|
||||
if [ -e "$(git rev-parse --git-path "$git_path")" ]; then
|
||||
die "A git operation is already in progress ($git_path). Finish it before running this script."
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
ensure_no_git_operation_in_progress() {
|
||||
local git_path
|
||||
ensure_no_nonrebase_git_operation_in_progress
|
||||
for git_path in rebase-merge rebase-apply MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD BISECT_LOG; do
|
||||
if [ -e "$(git rev-parse --git-path "$git_path")" ]; then
|
||||
die "A git operation is already in progress ($git_path). Finish it before running this script."
|
||||
@@ -50,6 +173,81 @@ ensure_current_branch() {
|
||||
[ -n "$CURRENT_BRANCH" ] || die "Detached HEAD is not supported. Check out a branch first."
|
||||
}
|
||||
|
||||
update_state_for_sync() {
|
||||
STATE_BRANCH="$CURRENT_BRANCH"
|
||||
STATE_BACKUP_BRANCH="$BACKUP_BRANCH"
|
||||
STATE_STASH_HASH="$STASH_HASH"
|
||||
STATE_REMOTE_NAME="$REMOTE_NAME"
|
||||
STATE_REMOTE_URL="$REMOTE_URL"
|
||||
STATE_TARGET_BRANCH="$BRANCH"
|
||||
write_state
|
||||
}
|
||||
|
||||
list_current_branch_backup_refs() {
|
||||
local safe_branch_name
|
||||
safe_branch_name="$(sanitize_ref_name "$CURRENT_BRANCH")"
|
||||
git for-each-ref --sort=-creatordate --format='%(refname:short)' "refs/heads/backup/${safe_branch_name}-*"
|
||||
}
|
||||
|
||||
show_current_branch_backups() {
|
||||
local backup_ref
|
||||
|
||||
if ! list_current_branch_backup_refs | grep -q .; then
|
||||
say "No existing backups were found for branch '$CURRENT_BRANCH'."
|
||||
return 1
|
||||
fi
|
||||
|
||||
say "Existing backups for branch '$CURRENT_BRANCH':"
|
||||
while IFS= read -r backup_ref; do
|
||||
[ -n "$backup_ref" ] || continue
|
||||
say " - $backup_ref"
|
||||
done < <(list_current_branch_backup_refs)
|
||||
return 0
|
||||
}
|
||||
|
||||
confirm_yes_no() {
|
||||
local prompt="$1" reply
|
||||
|
||||
while true; do
|
||||
printf '%s [Y/n]: ' "$prompt"
|
||||
read -r reply
|
||||
case "${reply,,}" in
|
||||
""|y|yes) return 0 ;;
|
||||
n|no) return 1 ;;
|
||||
*) say "Please answer yes or no." ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
prepare_backup_before_real_action() {
|
||||
local action_label="$1" backup_reason="$2" latest_existing_backup=""
|
||||
|
||||
STATE_START_HEAD="$(git rev-parse HEAD)"
|
||||
BACKUP_BRANCH=""
|
||||
|
||||
latest_existing_backup="$(list_current_branch_backup_refs | head -n 1 || true)"
|
||||
if [ -z "$latest_existing_backup" ]; then
|
||||
BACKUP_BRANCH="$(create_backup_branch_for_reason "$backup_reason")"
|
||||
say "No existing backups were found, so a new backup was created automatically: $BACKUP_BRANCH"
|
||||
return
|
||||
fi
|
||||
|
||||
show_current_branch_backups
|
||||
if [ -t 0 ] && [ -t 1 ]; then
|
||||
if confirm_yes_no "Create a fresh backup before '$action_label'?"; then
|
||||
BACKUP_BRANCH="$(create_backup_branch_for_reason "$backup_reason")"
|
||||
say "New backup created: $BACKUP_BRANCH"
|
||||
else
|
||||
BACKUP_BRANCH="$latest_existing_backup"
|
||||
say "No new backup created. The exact current commit was still recorded internally as: $STATE_START_HEAD"
|
||||
say "Latest existing backup selected as reference: $BACKUP_BRANCH"
|
||||
fi
|
||||
else
|
||||
BACKUP_BRANCH="$(create_backup_branch_for_reason "$backup_reason")"
|
||||
say "Non-interactive mode detected. A fresh backup was created automatically: $BACKUP_BRANCH"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_upstream_remote() {
|
||||
local current_remote_url
|
||||
|
||||
@@ -64,83 +262,541 @@ ensure_upstream_remote() {
|
||||
git remote add "$REMOTE_NAME" "$REMOTE_URL"
|
||||
}
|
||||
|
||||
create_backup_branch() {
|
||||
local safe_branch_name timestamp
|
||||
show_status() {
|
||||
local stash_count latest_backup dry_run_branch_dir
|
||||
|
||||
ensure_git_repo
|
||||
ensure_current_branch
|
||||
ensure_upstream_remote
|
||||
|
||||
stash_count="$(git stash list | wc -l | tr -d ' ')"
|
||||
|
||||
say "Repository: $(git rev-parse --show-toplevel)"
|
||||
say "Current branch: $CURRENT_BRANCH"
|
||||
say "Upstream remote: $REMOTE_NAME -> $REMOTE_URL"
|
||||
say "Working tree:"
|
||||
if ! worktree_has_local_changes; then
|
||||
say " clean"
|
||||
else
|
||||
git status --short
|
||||
fi
|
||||
say "Stash entries: $stash_count"
|
||||
latest_backup="$(find_latest_backup_branch_for_current || true)"
|
||||
say "Latest backup for current branch: ${latest_backup:-none}"
|
||||
dry_run_branch_dir="$DRY_RUN_ROOT/$(sanitize_ref_name "$CURRENT_BRANCH")"
|
||||
if [ -d "$dry_run_branch_dir/repo/.git" ]; then
|
||||
say "Dry-run workspace: $dry_run_branch_dir"
|
||||
else
|
||||
say "Dry-run workspace: not initialized"
|
||||
fi
|
||||
if load_state; then
|
||||
say "Saved sync state: $STATE_STATUS"
|
||||
say "Saved backup branch: ${STATE_BACKUP_BRANCH:-none}"
|
||||
say "Saved starting commit: ${STATE_START_HEAD:-none}"
|
||||
say "Saved original stash: ${STATE_STASH_HASH:-none}"
|
||||
else
|
||||
say "Saved sync state: none"
|
||||
fi
|
||||
}
|
||||
|
||||
create_backup_branch_for_reason() {
|
||||
local reason="$1" safe_branch_name timestamp branch_name
|
||||
safe_branch_name="${CURRENT_BRANCH//\//-}"
|
||||
timestamp="$(date +%Y%m%d-%H%M%S)"
|
||||
BACKUP_BRANCH="backup/${safe_branch_name}-before-upstream-sync-${timestamp}"
|
||||
branch_name="backup/${safe_branch_name}-${reason}-${timestamp}"
|
||||
|
||||
git branch "$BACKUP_BRANCH" HEAD >/dev/null
|
||||
git branch "$branch_name" HEAD >/dev/null
|
||||
printf '%s\n' "$branch_name"
|
||||
}
|
||||
|
||||
create_backup_branch() {
|
||||
BACKUP_BRANCH="$(create_backup_branch_for_reason "before-upstream-sync")"
|
||||
say "Backup branch created: $BACKUP_BRANCH"
|
||||
}
|
||||
|
||||
stash_local_changes() {
|
||||
local timestamp
|
||||
stash_current_changes() {
|
||||
local stash_prefix="$1" timestamp stash_name
|
||||
|
||||
STASH_REF=""
|
||||
if git diff --quiet && git diff --cached --quiet && [ -z "$(git ls-files --others --exclude-standard)" ]; then
|
||||
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="pre-upstream-sync-${CURRENT_BRANCH//\//-}-${timestamp}"
|
||||
stash_name="${stash_prefix}-${CURRENT_BRANCH//\//-}-${timestamp}"
|
||||
|
||||
say "Stashing local tracked and untracked changes..."
|
||||
git stash push --include-untracked --message "$STASH_NAME" >/dev/null
|
||||
git stash push --include-untracked --message "$stash_name" >/dev/null
|
||||
STASH_REF="$(git stash list -1 --format='%gd')"
|
||||
[ -n "$STASH_REF" ] || die "Failed to locate the created stash entry."
|
||||
STASH_HASH="$(git rev-parse "$STASH_REF")"
|
||||
[ -n "$STASH_HASH" ] || die "Failed to resolve the created stash hash."
|
||||
say "Local changes saved in $STASH_REF"
|
||||
}
|
||||
|
||||
stash_local_changes() {
|
||||
stash_current_changes "pre-upstream-sync"
|
||||
}
|
||||
|
||||
fetch_upstream() {
|
||||
say "Fetching latest changes from $REMOTE_NAME/$BRANCH..."
|
||||
git fetch --prune "$REMOTE_NAME"
|
||||
git show-ref --verify --quiet "refs/remotes/$REMOTE_NAME/$BRANCH" || die "Remote branch '$REMOTE_NAME/$BRANCH' was not found."
|
||||
}
|
||||
|
||||
show_rebase_conflict_details() {
|
||||
local repo_path="$1" conflict_label conflict_file found_conflicts=0
|
||||
|
||||
conflict_label="$(get_rebase_conflict_commit_label "$repo_path")"
|
||||
if [ -n "$conflict_label" ]; then
|
||||
say "Conflicting local commit: $conflict_label"
|
||||
fi
|
||||
|
||||
while IFS= read -r conflict_file; do
|
||||
if [ "$found_conflicts" -eq 0 ]; then
|
||||
say "Conflicted files:"
|
||||
found_conflicts=1
|
||||
fi
|
||||
say " - $conflict_file"
|
||||
if [ "$conflict_file" = "package.json" ]; then
|
||||
show_package_json_conflict_details "$repo_path" "$conflict_file"
|
||||
fi
|
||||
done < <(git -C "$repo_path" diff --name-only --diff-filter=U)
|
||||
|
||||
if [ "$found_conflicts" -eq 0 ]; then
|
||||
say "No explicit conflicted files were detected, but the rebase did stop."
|
||||
fi
|
||||
}
|
||||
|
||||
get_rebase_conflict_commit_label() {
|
||||
local repo_path="$1" rebase_head
|
||||
|
||||
rebase_head="$(git -C "$repo_path" rev-parse --short REBASE_HEAD 2>/dev/null || true)"
|
||||
if [ -n "$rebase_head" ]; then
|
||||
git -C "$repo_path" show --no-patch --format='%h %s' REBASE_HEAD
|
||||
fi
|
||||
}
|
||||
|
||||
array_contains_item() {
|
||||
local item="$1" existing
|
||||
shift || true
|
||||
for existing in "$@"; do
|
||||
if [ "$existing" = "$item" ]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
extract_package_json_scripts_block() {
|
||||
sed -n '/"scripts"[[:space:]]*:[[:space:]]*{/,/^[[:space:]]*}[[:space:]]*,\{0,1\}[[:space:]]*$/p'
|
||||
}
|
||||
|
||||
show_package_json_conflict_details() {
|
||||
local repo_path="$1" file_path="$2" upstream_side conflict_commit_side current_branch_side
|
||||
|
||||
upstream_side="$(git -C "$repo_path" show ":2:$file_path" 2>/dev/null || true)"
|
||||
conflict_commit_side="$(git -C "$repo_path" show ":3:$file_path" 2>/dev/null || true)"
|
||||
current_branch_side="$(cat "$REPO_ROOT/$file_path" 2>/dev/null || true)"
|
||||
|
||||
say " package.json scripts comparison:"
|
||||
if [ -n "$upstream_side" ]; then
|
||||
say " Upstream/current side:"
|
||||
printf '%s\n' "$upstream_side" | extract_package_json_scripts_block | sed 's/^/ /'
|
||||
fi
|
||||
if [ -n "$conflict_commit_side" ]; then
|
||||
say " Conflicting commit side:"
|
||||
printf '%s\n' "$conflict_commit_side" | extract_package_json_scripts_block | sed 's/^/ /'
|
||||
fi
|
||||
if [ -n "$current_branch_side" ]; then
|
||||
say " Current branch working tree side:"
|
||||
printf '%s\n' "$current_branch_side" | extract_package_json_scripts_block | sed 's/^/ /'
|
||||
fi
|
||||
}
|
||||
|
||||
auto_resolve_current_rebase_conflicts_in_temp_clone() {
|
||||
local repo_path="$1" conflict_file
|
||||
|
||||
while IFS= read -r conflict_file; do
|
||||
if ! git -C "$repo_path" checkout --theirs -- "$conflict_file" 2>/dev/null; then
|
||||
git -C "$repo_path" rm --force -- "$conflict_file" >/dev/null 2>&1 || true
|
||||
fi
|
||||
git -C "$repo_path" add -A -- "$conflict_file"
|
||||
done < <(git -C "$repo_path" diff --name-only --diff-filter=U)
|
||||
}
|
||||
|
||||
collect_all_rebase_conflicts_in_temp_clone() {
|
||||
local repo_path="$1" conflict_count=0 commit_label conflict_file
|
||||
local -a conflict_commits=()
|
||||
local -a unique_conflict_files=()
|
||||
|
||||
while has_rebase_in_progress_in_repo "$repo_path"; do
|
||||
conflict_count=$((conflict_count + 1))
|
||||
commit_label="$(get_rebase_conflict_commit_label "$repo_path")"
|
||||
if [ -n "$commit_label" ] && ! array_contains_item "$commit_label" "${conflict_commits[@]}"; then
|
||||
conflict_commits+=("$commit_label")
|
||||
fi
|
||||
while IFS= read -r conflict_file; do
|
||||
[ -n "$conflict_file" ] || continue
|
||||
if ! array_contains_item "$conflict_file" "${unique_conflict_files[@]}"; then
|
||||
unique_conflict_files+=("$conflict_file")
|
||||
fi
|
||||
done < <(git -C "$repo_path" diff --name-only --diff-filter=U)
|
||||
|
||||
say "Conflict step #$conflict_count:"
|
||||
show_rebase_conflict_details "$repo_path"
|
||||
auto_resolve_current_rebase_conflicts_in_temp_clone "$repo_path"
|
||||
if ! GIT_EDITOR=: git -C "$repo_path" rebase --continue >/dev/null 2>&1; then
|
||||
if has_rebase_in_progress_in_repo "$repo_path"; then
|
||||
continue
|
||||
fi
|
||||
say "Dry-run could not continue after automatically recording a conflict step."
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
say "Total detected rebase conflict steps: $conflict_count"
|
||||
if [ "${#conflict_commits[@]}" -gt 0 ]; then
|
||||
say "Local commits with conflicts (${#conflict_commits[@]}):"
|
||||
for commit_label in "${conflict_commits[@]}"; do
|
||||
say " - $commit_label"
|
||||
done
|
||||
fi
|
||||
if [ "${#unique_conflict_files[@]}" -gt 0 ]; then
|
||||
say "Unique conflicted files (${#unique_conflict_files[@]}):"
|
||||
for conflict_file in "${unique_conflict_files[@]}"; do
|
||||
say " - $conflict_file"
|
||||
done
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
prepare_dry_run_workspace() {
|
||||
local dry_run_branch_dir="$1" dry_run_repo="$2"
|
||||
|
||||
mkdir -p "$dry_run_branch_dir"
|
||||
|
||||
if [ ! -d "$dry_run_repo/.git" ]; then
|
||||
say "Creating reusable dry-run clone in $dry_run_repo..."
|
||||
git clone --quiet --no-local --branch "$CURRENT_BRANCH" --single-branch "$REPO_ROOT" "$dry_run_repo"
|
||||
else
|
||||
say "Reusing dry-run clone in $dry_run_repo..."
|
||||
if has_rebase_in_progress_in_repo "$dry_run_repo"; then
|
||||
git -C "$dry_run_repo" rebase --abort >/dev/null 2>&1 || true
|
||||
fi
|
||||
git -C "$dry_run_repo" reset --hard >/dev/null
|
||||
git -C "$dry_run_repo" clean -fdx >/dev/null
|
||||
git -C "$dry_run_repo" remote set-url origin "$REPO_ROOT"
|
||||
git -C "$dry_run_repo" fetch --prune origin "$CURRENT_BRANCH"
|
||||
git -C "$dry_run_repo" checkout -B "$CURRENT_BRANCH" "origin/$CURRENT_BRANCH" >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
if git -C "$dry_run_repo" remote get-url "$REMOTE_NAME" >/dev/null 2>&1; then
|
||||
git -C "$dry_run_repo" remote set-url "$REMOTE_NAME" "$REMOTE_URL"
|
||||
else
|
||||
git -C "$dry_run_repo" remote add "$REMOTE_NAME" "$REMOTE_URL"
|
||||
fi
|
||||
}
|
||||
|
||||
run_clean_dry_run() {
|
||||
local tmp_root legacy_dir removed_any=0
|
||||
|
||||
ensure_git_repo
|
||||
|
||||
if [ -d "$DRY_RUN_ROOT" ]; then
|
||||
rm -rf "$DRY_RUN_ROOT"
|
||||
say "Removed reusable dry-run workspace: $DRY_RUN_ROOT"
|
||||
removed_any=1
|
||||
fi
|
||||
|
||||
tmp_root="${TMPDIR:-/tmp}"
|
||||
shopt -s nullglob
|
||||
for legacy_dir in "$tmp_root"/gitea-upstream-test.*; do
|
||||
if [ -d "$legacy_dir" ]; then
|
||||
rm -rf "$legacy_dir"
|
||||
say "Removed legacy dry-run directory: $legacy_dir"
|
||||
removed_any=1
|
||||
fi
|
||||
done
|
||||
shopt -u nullglob
|
||||
|
||||
if [ "$removed_any" -eq 0 ]; then
|
||||
say "No dry-run workspace or legacy temporary directories were found."
|
||||
fi
|
||||
}
|
||||
|
||||
rebase_onto_upstream() {
|
||||
say "Rebasing '$CURRENT_BRANCH' onto '$REMOTE_NAME/$BRANCH'..."
|
||||
if git rebase "$REMOTE_NAME/$BRANCH"; then
|
||||
say "Rebase completed successfully."
|
||||
STATE_STATUS="rebase_completed"
|
||||
update_state_for_sync
|
||||
return
|
||||
fi
|
||||
|
||||
STATE_STATUS="rebase_conflict"
|
||||
update_state_for_sync
|
||||
say "Rebase stopped because of conflicts."
|
||||
show_rebase_conflict_details "."
|
||||
say "Backup branch kept at: $BACKUP_BRANCH"
|
||||
if [ -n "$STASH_REF" ]; then
|
||||
say "Local stash kept at: $STASH_REF"
|
||||
if [ -n "$STASH_HASH" ]; then
|
||||
say "Local stash kept at: $STASH_HASH"
|
||||
fi
|
||||
say "Resolve conflicts and run 'git rebase --continue', or abort with 'git rebase --abort'."
|
||||
exit 1
|
||||
}
|
||||
|
||||
find_stash_ref_by_hash() {
|
||||
local target_hash="$1" hash ref
|
||||
while read -r hash ref; do
|
||||
if [ "$hash" = "$target_hash" ]; then
|
||||
printf '%s\n' "$ref"
|
||||
return 0
|
||||
fi
|
||||
done < <(git stash list --format='%H %gd')
|
||||
return 1
|
||||
}
|
||||
|
||||
drop_stash_by_hash_if_present() {
|
||||
local target_hash="$1" stash_ref
|
||||
[ -n "$target_hash" ] || return 0
|
||||
if stash_ref="$(find_stash_ref_by_hash "$target_hash")"; then
|
||||
git stash drop "$stash_ref" >/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
restore_stash() {
|
||||
if [ -z "$STASH_REF" ]; then
|
||||
local restore_target
|
||||
|
||||
if [ -z "$STASH_HASH" ] && [ -z "$STASH_REF" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
say "Restoring stashed local changes from $STASH_REF..."
|
||||
if git stash apply --index "$STASH_REF"; then
|
||||
git stash drop "$STASH_REF" >/dev/null
|
||||
restore_target="${STASH_HASH:-$STASH_REF}"
|
||||
say "Restoring stashed local changes from $restore_target..."
|
||||
if git stash apply --index "$restore_target"; then
|
||||
drop_stash_by_hash_if_present "$STASH_HASH"
|
||||
STASH_REF=""
|
||||
STASH_HASH=""
|
||||
STATE_STASH_HASH=""
|
||||
STATE_STATUS="completed"
|
||||
update_state_for_sync
|
||||
say "Local changes restored successfully."
|
||||
return
|
||||
fi
|
||||
|
||||
STATE_STATUS="restore_conflict"
|
||||
update_state_for_sync
|
||||
say "Conflicts occurred while restoring local changes."
|
||||
say "Backup branch kept at: $BACKUP_BRANCH"
|
||||
say "Stash kept at: $STASH_REF"
|
||||
say "Stash kept at: $restore_target"
|
||||
say "Resolve the conflicts manually. After that, drop the stash yourself if it is no longer needed."
|
||||
exit 1
|
||||
}
|
||||
|
||||
main() {
|
||||
find_latest_backup_branch_for_current() {
|
||||
local safe_branch_name
|
||||
safe_branch_name="$(sanitize_ref_name "$CURRENT_BRANCH")"
|
||||
git for-each-ref --sort=-creatordate --format='%(refname:short)' "refs/heads/backup/${safe_branch_name}-before-upstream-sync-*" | head -n 1
|
||||
}
|
||||
|
||||
run_list_backups() {
|
||||
ensure_git_repo
|
||||
if ! git for-each-ref --format='%(refname:short)' refs/heads/backup/ | grep -q .; then
|
||||
say "No backup branches found."
|
||||
return
|
||||
fi
|
||||
|
||||
git for-each-ref --sort=-creatordate \
|
||||
--format='%(refname:short) | %(creatordate:iso8601-local) | %(objectname:short) | %(subject)' \
|
||||
refs/heads/backup/
|
||||
}
|
||||
|
||||
run_restore_saved_stash() {
|
||||
ensure_git_repo
|
||||
ensure_no_git_operation_in_progress
|
||||
ensure_current_branch
|
||||
load_state || die "No saved sync state was found."
|
||||
if [ -n "${STATE_BRANCH:-}" ] && [ "$CURRENT_BRANCH" != "$STATE_BRANCH" ]; then
|
||||
die "Saved sync state belongs to branch '$STATE_BRANCH'. Check out that branch before restoring the saved stash."
|
||||
fi
|
||||
[ -n "$STATE_STASH_HASH" ] || die "There is no saved pre-sync stash to restore."
|
||||
if worktree_has_local_changes; then
|
||||
die "The working tree is not clean. Commit, stash, or discard current changes before restoring the saved stash."
|
||||
fi
|
||||
|
||||
prepare_backup_before_real_action "restore-stash" "before-restore-stash"
|
||||
|
||||
STASH_HASH="$STATE_STASH_HASH"
|
||||
STASH_REF=""
|
||||
restore_stash
|
||||
say "Saved pre-sync local changes restored."
|
||||
}
|
||||
|
||||
run_rollback() {
|
||||
local explicit_target=0 target_backup="${1:-}" rollback_safety_backup preserve_stash_ref preserve_stash_hash reset_target=""
|
||||
|
||||
ensure_git_repo
|
||||
ensure_no_nonrebase_git_operation_in_progress
|
||||
ensure_current_branch
|
||||
ensure_upstream_remote
|
||||
|
||||
if [ -n "$target_backup" ]; then
|
||||
explicit_target=1
|
||||
fi
|
||||
|
||||
load_state || true
|
||||
if [ "$explicit_target" -eq 0 ] && [ -n "${STATE_BRANCH:-}" ] && [ "$CURRENT_BRANCH" != "$STATE_BRANCH" ]; then
|
||||
die "Saved sync state belongs to branch '$STATE_BRANCH'. Check out that branch or pass an explicit backup branch."
|
||||
fi
|
||||
if [ "$explicit_target" -eq 0 ] && [ -n "${STATE_START_HEAD:-}" ]; then
|
||||
reset_target="$STATE_START_HEAD"
|
||||
fi
|
||||
if [ -z "$reset_target" ] && [ -z "$target_backup" ]; then
|
||||
target_backup="${STATE_BACKUP_BRANCH:-}"
|
||||
fi
|
||||
if [ -z "$reset_target" ] && [ -z "$target_backup" ]; then
|
||||
target_backup="$(find_latest_backup_branch_for_current || true)"
|
||||
fi
|
||||
if [ -z "$reset_target" ]; then
|
||||
[ -n "$target_backup" ] || die "No backup branch was found for rollback."
|
||||
git show-ref --verify --quiet "refs/heads/$target_backup" || die "Backup branch '$target_backup' does not exist."
|
||||
reset_target="$target_backup"
|
||||
fi
|
||||
|
||||
if has_rebase_in_progress; then
|
||||
say "Aborting in-progress rebase before rollback..."
|
||||
git rebase --abort || die "Failed to abort the in-progress rebase."
|
||||
fi
|
||||
|
||||
if [ -n "$(git diff --name-only --diff-filter=U)" ]; then
|
||||
die "There are still unmerged files in the working tree. Resolve or discard them before rollback."
|
||||
fi
|
||||
|
||||
prepare_backup_before_real_action "rollback" "before-rollback"
|
||||
rollback_safety_backup="$BACKUP_BRANCH"
|
||||
|
||||
preserve_stash_ref=""
|
||||
preserve_stash_hash=""
|
||||
if worktree_has_local_changes; then
|
||||
stash_current_changes "pre-rollback-preserve"
|
||||
preserve_stash_ref="$STASH_REF"
|
||||
preserve_stash_hash="$STASH_HASH"
|
||||
fi
|
||||
|
||||
say "Resetting '$CURRENT_BRANCH' to '$reset_target'..."
|
||||
git reset --hard "$reset_target" >/dev/null
|
||||
|
||||
if [ -n "${STATE_STASH_HASH:-}" ]; then
|
||||
STASH_HASH="$STATE_STASH_HASH"
|
||||
STASH_REF=""
|
||||
BACKUP_BRANCH="$rollback_safety_backup"
|
||||
say "Restoring original pre-sync local changes..."
|
||||
restore_stash
|
||||
fi
|
||||
|
||||
if [ -n "$preserve_stash_hash" ]; then
|
||||
STASH_HASH="$preserve_stash_hash"
|
||||
STASH_REF="$preserve_stash_ref"
|
||||
BACKUP_BRANCH="$rollback_safety_backup"
|
||||
say "Restoring local changes that existed before rollback..."
|
||||
restore_stash
|
||||
fi
|
||||
|
||||
clear_state
|
||||
say "Rollback completed."
|
||||
say "Current branch restored from: $reset_target"
|
||||
say "Safety backup of the pre-rollback state kept at: $rollback_safety_backup"
|
||||
}
|
||||
|
||||
run_dry_run_sync() {
|
||||
local dry_run_branch_dir temp_repo patch_file has_tracked_changes has_untracked_changes
|
||||
local dry_run_has_conflicts=0 untracked_conflicts=0 path
|
||||
|
||||
ensure_git_repo
|
||||
ensure_no_git_operation_in_progress
|
||||
ensure_current_branch
|
||||
ensure_upstream_remote
|
||||
create_backup_branch
|
||||
|
||||
dry_run_branch_dir="$DRY_RUN_ROOT/$(sanitize_ref_name "$CURRENT_BRANCH")"
|
||||
temp_repo="$dry_run_branch_dir/repo"
|
||||
patch_file="$dry_run_branch_dir/local-changes.patch"
|
||||
has_tracked_changes=0
|
||||
has_untracked_changes=0
|
||||
|
||||
prepare_dry_run_workspace "$dry_run_branch_dir" "$temp_repo"
|
||||
|
||||
say "Testing upstream fetch in the dry-run clone..."
|
||||
git -C "$temp_repo" fetch --prune "$REMOTE_NAME"
|
||||
git -C "$temp_repo" show-ref --verify --quiet "refs/remotes/$REMOTE_NAME/$BRANCH" || die "Remote branch '$REMOTE_NAME/$BRANCH' was not found during test."
|
||||
|
||||
say "Testing rebase in the dry-run clone..."
|
||||
if ! git -C "$temp_repo" rebase "$REMOTE_NAME/$BRANCH"; then
|
||||
say "Dry-run failed during rebase."
|
||||
if ! collect_all_rebase_conflicts_in_temp_clone "$temp_repo"; then
|
||||
say "Dry-run could not enumerate all remaining rebase conflicts."
|
||||
say "Current repository was not modified."
|
||||
say "Dry-run workspace kept at: $temp_repo"
|
||||
exit 1
|
||||
fi
|
||||
dry_run_has_conflicts=1
|
||||
fi
|
||||
|
||||
if ! git diff --quiet HEAD || ! git diff --cached --quiet; then
|
||||
has_tracked_changes=1
|
||||
git diff --binary HEAD >"$patch_file"
|
||||
if [ -s "$patch_file" ]; then
|
||||
say "Testing re-apply of tracked local changes..."
|
||||
if ! git -C "$temp_repo" apply --3way --index "$patch_file"; then
|
||||
say "Dry-run detected tracked local-change conflicts during re-apply."
|
||||
dry_run_has_conflicts=1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$(git ls-files --others --exclude-standard)" ]; then
|
||||
has_untracked_changes=1
|
||||
say "Testing collisions for untracked local files..."
|
||||
while IFS= read -r -d '' path; do
|
||||
if [ -e "$temp_repo/$path" ]; then
|
||||
say "Untracked file would conflict after sync: $path"
|
||||
untracked_conflicts=1
|
||||
fi
|
||||
done < <(git ls-files --others --exclude-standard -z)
|
||||
fi
|
||||
|
||||
if [ "$untracked_conflicts" -ne 0 ]; then
|
||||
say "Dry-run detected untracked-file conflicts."
|
||||
dry_run_has_conflicts=1
|
||||
fi
|
||||
|
||||
if [ "$dry_run_has_conflicts" -ne 0 ]; then
|
||||
say "Dry-run completed with conflicts detected."
|
||||
say "A real sync would require manual conflict resolution."
|
||||
say "Current repository was not modified."
|
||||
say "Dry-run workspace kept at: $temp_repo"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$has_tracked_changes" -eq 0 ] && [ "$has_untracked_changes" -eq 0 ]; then
|
||||
say "Dry-run passed. No local changes needed to be re-applied."
|
||||
else
|
||||
say "Dry-run passed. Local changes appear safe to restore after sync."
|
||||
fi
|
||||
say "Dry-run workspace ready for reuse at: $temp_repo"
|
||||
}
|
||||
|
||||
run_sync() {
|
||||
ensure_git_repo
|
||||
ensure_no_git_operation_in_progress
|
||||
ensure_current_branch
|
||||
ensure_upstream_remote
|
||||
prepare_backup_before_real_action "sync" "before-upstream-sync"
|
||||
stash_local_changes
|
||||
STATE_STATUS="prepared"
|
||||
update_state_for_sync
|
||||
fetch_upstream
|
||||
rebase_onto_upstream
|
||||
restore_stash
|
||||
@@ -148,4 +804,84 @@ main() {
|
||||
say "Safety backup branch available at: $BACKUP_BRANCH"
|
||||
}
|
||||
|
||||
run_fetch_only() {
|
||||
ensure_git_repo
|
||||
ensure_no_git_operation_in_progress
|
||||
ensure_current_branch
|
||||
ensure_upstream_remote
|
||||
fetch_upstream
|
||||
say "Latest changes fetched from $REMOTE_NAME/$BRANCH."
|
||||
}
|
||||
|
||||
run_backup_only() {
|
||||
ensure_git_repo
|
||||
ensure_no_git_operation_in_progress
|
||||
ensure_current_branch
|
||||
ensure_upstream_remote
|
||||
create_backup_branch
|
||||
}
|
||||
|
||||
show_menu() {
|
||||
local choice
|
||||
|
||||
while true; do
|
||||
cat <<EOF
|
||||
Available actions:
|
||||
1) Full safe sync
|
||||
2) Dry-run
|
||||
3) Fetch upstream only
|
||||
4) Create backup branch only
|
||||
5) List backup branches
|
||||
6) Rollback to saved backup
|
||||
7) Restore saved stash
|
||||
8) Clean dry-run workspace
|
||||
9) Show repository status
|
||||
10) Help
|
||||
0) Exit
|
||||
EOF
|
||||
printf 'Choose an action: '
|
||||
read -r choice
|
||||
|
||||
case "$choice" in
|
||||
1) run_sync; return ;;
|
||||
2) run_dry_run_sync; return ;;
|
||||
3) run_fetch_only; return ;;
|
||||
4) run_backup_only; return ;;
|
||||
5) run_list_backups ;;
|
||||
6) run_rollback; return ;;
|
||||
7) run_restore_saved_stash; return ;;
|
||||
8) run_clean_dry_run ;;
|
||||
9) show_status ;;
|
||||
10) show_usage ;;
|
||||
0) exit 0 ;;
|
||||
*) say "Invalid menu selection: $choice" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
main() {
|
||||
case "${1:-menu}" in
|
||||
sync) run_sync ;;
|
||||
dry-run|test) run_dry_run_sync ;;
|
||||
fetch) run_fetch_only ;;
|
||||
backup) run_backup_only ;;
|
||||
list-backups) run_list_backups ;;
|
||||
rollback) run_rollback "${2:-}" ;;
|
||||
restore-stash) run_restore_saved_stash ;;
|
||||
clean-dry-run) run_clean_dry_run ;;
|
||||
status) show_status ;;
|
||||
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 "$@"
|
||||
|
||||
Reference in New Issue
Block a user