|
| 1 | +#!/bin/bash |
| 2 | + |
| 3 | +# Merge Previous Release Branches Script |
| 4 | +# |
| 5 | +# This script is triggered when a new release branch is created (e.g., release/2.1.2). |
| 6 | +# It finds all previous release branches and merges them into the new release branch. |
| 7 | +# |
| 8 | +# Key behaviors: |
| 9 | +# - Merges ALL older release branches into the new one |
| 10 | +# - For merge conflicts, favors the destination branch (new release) |
| 11 | +# - Both branches remain open after merge |
| 12 | +# - Fails fast on errors to prevent pushing partial merges |
| 13 | +# |
| 14 | +# Environment variables: |
| 15 | +# - NEW_RELEASE_BRANCH: The newly created release branch (e.g., release/2.1.2) |
| 16 | + |
| 17 | +set -e |
| 18 | + |
| 19 | +# Parse a release branch name to extract version components |
| 20 | +# Returns: "major minor patch" or empty string if not valid |
| 21 | +parse_release_version() { |
| 22 | + local branch_name="$1" |
| 23 | + if [[ "$branch_name" =~ ^release/([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then |
| 24 | + echo "${BASH_REMATCH[1]} ${BASH_REMATCH[2]} ${BASH_REMATCH[3]}" |
| 25 | + fi |
| 26 | +} |
| 27 | + |
| 28 | +# Check if version A is older than version B |
| 29 | +# Returns: exit code 0 if a < b, 1 otherwise |
| 30 | +is_version_older() { |
| 31 | + local a_major="$1" a_minor="$2" a_patch="$3" |
| 32 | + local b_major="$4" b_minor="$5" b_patch="$6" |
| 33 | + |
| 34 | + if [[ "$a_major" -lt "$b_major" ]]; then return 0; fi |
| 35 | + if [[ "$a_major" -gt "$b_major" ]]; then return 1; fi |
| 36 | + if [[ "$a_minor" -lt "$b_minor" ]]; then return 0; fi |
| 37 | + if [[ "$a_minor" -gt "$b_minor" ]]; then return 1; fi |
| 38 | + if [[ "$a_patch" -lt "$b_patch" ]]; then return 0; fi |
| 39 | + return 1 |
| 40 | +} |
| 41 | + |
| 42 | +# Execute a git command and log it |
| 43 | +git_exec() { |
| 44 | + echo "Executing: git $*" |
| 45 | + git "$@" |
| 46 | +} |
| 47 | + |
| 48 | +# Check if a branch has already been merged into the current branch. If yes, we skip merging it again. |
| 49 | +# Returns: exit code 0 if merged, 1 if not merged |
| 50 | +is_branch_merged() { |
| 51 | + local source_branch="$1" |
| 52 | + git merge-base --is-ancestor "origin/${source_branch}" HEAD 2>/dev/null |
| 53 | +} |
| 54 | + |
| 55 | +# Merge a source branch (older release branch) into the current branch (new release branch), favoring current branch on conflicts |
| 56 | +merge_with_favor_destination() { |
| 57 | + local source_branch="$1" |
| 58 | + local dest_branch="$2" |
| 59 | + |
| 60 | + echo "" |
| 61 | + echo "============================================================" |
| 62 | + echo "Merging ${source_branch} into ${dest_branch}" |
| 63 | + echo "============================================================" |
| 64 | + |
| 65 | + # Check if already merged |
| 66 | + if is_branch_merged "$source_branch"; then |
| 67 | + echo "Branch ${source_branch} is already merged into ${dest_branch}. Skipping." |
| 68 | + return 1 # Return 1 to indicate skipped |
| 69 | + fi |
| 70 | + |
| 71 | + # Try to merge with "ours" strategy for conflicts (favors current branch (new release)) |
| 72 | + if git_exec merge "origin/${source_branch}" -X ours --no-edit -m "Merge ${source_branch} into ${dest_branch}"; then |
| 73 | + echo "✅ Successfully merged ${source_branch} into ${dest_branch}" |
| 74 | + return 0 # Return 0 to indicate merged |
| 75 | + fi |
| 76 | + |
| 77 | + # If merge still fails (shouldn't happen with -X ours, but just in case) |
| 78 | + # First verify we're actually in a merge state (MERGE_HEAD exists) |
| 79 | + if [[ ! -f .git/MERGE_HEAD ]]; then |
| 80 | + echo "❌ Merge failed unexpectedly (no merge state). Aborting." |
| 81 | + exit 1 |
| 82 | + fi |
| 83 | + |
| 84 | + echo "⚠️ Merge conflict detected! Resolving by favoring destination branch (new release)..." |
| 85 | + |
| 86 | + # Resolve any unmerged (conflicted) files by keeping destination version. |
| 87 | + # |
| 88 | + # Git merge terminology in this context: |
| 89 | + # - "ours" = destination branch (new release, e.g., release/2.1.2) - the branch we're ON |
| 90 | + # - "theirs" = source branch (older release, e.g., release/2.1.1) - the branch being merged IN |
| 91 | + # |
| 92 | + # We favor "ours" (destination) because the new release branch should take precedence. |
| 93 | + local conflict_files |
| 94 | + local conflict_count=0 |
| 95 | + conflict_files=$(git diff --name-only --diff-filter=U 2>/dev/null || true) |
| 96 | + if [[ -n "$conflict_files" ]]; then |
| 97 | + while IFS= read -r file; do |
| 98 | + if [[ -n "$file" ]]; then |
| 99 | + echo " - Conflict in: ${file} → keeping destination version" |
| 100 | + # Try to checkout destination version ("ours") |
| 101 | + # If checkout fails, the file was deleted in destination - keep that deletion |
| 102 | + if git checkout --ours "$file" 2>/dev/null; then |
| 103 | + git add "$file" |
| 104 | + else |
| 105 | + # Modify/delete conflict scenario: |
| 106 | + # - Destination branch (new release) ALREADY deleted this file intentionally |
| 107 | + # - Source branch (older release) modified this file |
| 108 | + # - Git doesn't know which action to keep |
| 109 | + # |
| 110 | + # We use "git rm" to confirm the deletion should stand (destination wins). |
| 111 | + # This does NOT delete a file that exists - it tells Git "keep the file deleted". |
| 112 | + # The --force flag is required because the file is in a conflicted/unmerged state. |
| 113 | + echo " (file was deleted in destination, keeping deletion)" |
| 114 | + git rm --force "$file" 2>/dev/null || true |
| 115 | + fi |
| 116 | + ((conflict_count++)) || true |
| 117 | + fi |
| 118 | + done <<< "$conflict_files" |
| 119 | + echo "✅ Resolved ${conflict_count} conflict(s) by keeping destination branch version" |
| 120 | + fi |
| 121 | + |
| 122 | + # Now add any remaining files (non-conflicted changes), excluding github-tools directory |
| 123 | + git_exec add -- . ':!github-tools' |
| 124 | + |
| 125 | + # Complete the merge - always commit when in merge state, even if no content changes |
| 126 | + # Check if we're in a merge state (MERGE_HEAD exists) |
| 127 | + if [[ -f .git/MERGE_HEAD ]]; then |
| 128 | + if ! git_exec commit -m "Merge ${source_branch} into ${dest_branch}" --no-verify --allow-empty; then |
| 129 | + echo "Failed to commit merge of ${source_branch}" |
| 130 | + exit 1 |
| 131 | + fi |
| 132 | + fi |
| 133 | + |
| 134 | + echo "✅ Successfully merged ${source_branch} into ${dest_branch} (${conflict_count} conflict(s) resolved)" |
| 135 | + return 0 # Return 0 to indicate merged |
| 136 | +} |
| 137 | + |
| 138 | +main() { |
| 139 | + if [[ -z "$NEW_RELEASE_BRANCH" ]]; then |
| 140 | + echo "Error: NEW_RELEASE_BRANCH environment variable is not set" |
| 141 | + exit 1 |
| 142 | + fi |
| 143 | + |
| 144 | + echo "New release branch: ${NEW_RELEASE_BRANCH}" |
| 145 | + |
| 146 | + # Parse the new release version |
| 147 | + local new_version |
| 148 | + new_version=$(parse_release_version "$NEW_RELEASE_BRANCH") |
| 149 | + if [[ -z "$new_version" ]]; then |
| 150 | + echo "Error: ${NEW_RELEASE_BRANCH} is not a valid release branch (expected format: release/X.Y.Z)" |
| 151 | + exit 1 |
| 152 | + fi |
| 153 | + |
| 154 | + read -r new_major new_minor new_patch <<< "$new_version" |
| 155 | + echo "Parsed version: ${new_major}.${new_minor}.${new_patch}" |
| 156 | + |
| 157 | + # Fetch all remote branches |
| 158 | + git_exec fetch origin |
| 159 | + |
| 160 | + # Get all release branches |
| 161 | + local all_release_branches=() |
| 162 | + while IFS= read -r branch; do |
| 163 | + # Remove "origin/" prefix and whitespace |
| 164 | + branch="${branch#*origin/}" |
| 165 | + branch="${branch// /}" |
| 166 | + if [[ -n "$branch" ]] && [[ -n "$(parse_release_version "$branch")" ]]; then |
| 167 | + all_release_branches+=("$branch") |
| 168 | + fi |
| 169 | + done < <(git branch -r --list "origin/release/*") |
| 170 | + |
| 171 | + echo "" |
| 172 | + echo "Found ${#all_release_branches[@]} release branches:" |
| 173 | + for b in "${all_release_branches[@]}"; do |
| 174 | + echo " - $b" |
| 175 | + done |
| 176 | + |
| 177 | + # Filter to only branches older than the new one |
| 178 | + local older_branches=() |
| 179 | + for branch in "${all_release_branches[@]}"; do |
| 180 | + local version |
| 181 | + version=$(parse_release_version "$branch") |
| 182 | + if [[ -n "$version" ]]; then |
| 183 | + read -r major minor patch <<< "$version" |
| 184 | + if is_version_older "$major" "$minor" "$patch" "$new_major" "$new_minor" "$new_patch"; then |
| 185 | + older_branches+=("$branch") |
| 186 | + fi |
| 187 | + fi |
| 188 | + done |
| 189 | + |
| 190 | + # Sort older branches from oldest to newest using version sort |
| 191 | + local sorted_branches=() |
| 192 | + while IFS= read -r branch; do |
| 193 | + [[ -n "$branch" ]] && sorted_branches+=("$branch") |
| 194 | + done < <(printf '%s\n' "${older_branches[@]}" | sort -V) |
| 195 | + older_branches=("${sorted_branches[@]}") |
| 196 | + |
| 197 | + if [[ ${#older_branches[@]} -eq 0 ]]; then |
| 198 | + echo "" |
| 199 | + echo "No older release branches found. Nothing to merge." |
| 200 | + exit 0 |
| 201 | + fi |
| 202 | + |
| 203 | + echo "" |
| 204 | + echo "Older release branches found (oldest to newest):" |
| 205 | + for b in "${older_branches[@]}"; do |
| 206 | + echo " - $b" |
| 207 | + done |
| 208 | + |
| 209 | + echo "" |
| 210 | + echo "Will merge all ${#older_branches[@]} older branches." |
| 211 | + |
| 212 | + # Verify we're on the right branch |
| 213 | + local current_branch |
| 214 | + current_branch=$(git branch --show-current) |
| 215 | + if [[ "$current_branch" != "$NEW_RELEASE_BRANCH" ]]; then |
| 216 | + echo "Switching to ${NEW_RELEASE_BRANCH}..." |
| 217 | + git_exec checkout "$NEW_RELEASE_BRANCH" |
| 218 | + fi |
| 219 | + |
| 220 | + # Merge each branch (fail fast on errors) |
| 221 | + local merged_count=0 |
| 222 | + local skipped_count=0 |
| 223 | + |
| 224 | + for older_branch in "${older_branches[@]}"; do |
| 225 | + if merge_with_favor_destination "$older_branch" "$NEW_RELEASE_BRANCH"; then |
| 226 | + ((merged_count++)) || true |
| 227 | + else |
| 228 | + ((skipped_count++)) || true |
| 229 | + fi |
| 230 | + done |
| 231 | + |
| 232 | + # Only push if we actually merged something |
| 233 | + if [[ "$merged_count" -gt 0 ]]; then |
| 234 | + echo "" |
| 235 | + echo "Pushing merged changes..." |
| 236 | + git_exec push origin "$NEW_RELEASE_BRANCH" |
| 237 | + else |
| 238 | + echo "" |
| 239 | + echo "No new merges were made (all branches were already merged)." |
| 240 | + fi |
| 241 | + |
| 242 | + echo "" |
| 243 | + echo "============================================================" |
| 244 | + echo "Merge complete!" |
| 245 | + echo " Branches merged: ${merged_count}" |
| 246 | + echo " Branches skipped (already merged): ${skipped_count}" |
| 247 | + echo "All source branches remain open as requested." |
| 248 | + echo "============================================================" |
| 249 | +} |
| 250 | + |
| 251 | +# Run main and handle errors |
| 252 | +main "$@" |
0 commit comments