#!/usr/bin/env bash # Port a change made in one Minecraft version's code to the other versions. # # Takes the diff under one version's source folder and applies it to the others # with `git apply --3way`: it applies cleanly where the files match, and leaves # normal merge conflicts only where a version really differs. Only source is # ported; build.gradle and gradle.properties are version-specific on purpose. # # Versions moved to Stonecutter (stonecutterVersions in settings.gradle.kts) # share the top-level src/, which counts as one version: the active one set in # stonecutter.gradle.kts. The others keep their own copy in versions//src. # # Usage: # scripts/port.sh [--from VERSION] [--to V1,V2,...] [--commit REV] [--dry-run] # # --from version the change was made in. Default: the only version with # uncommitted changes (or changed by --commit). # --to comma-separated versions to port to. Default: all others. # --commit port the changes a commit made instead of uncommitted changes. # --dry-run report what would happen without changing anything. # # Exit status: 0 if every version applied cleanly (or already had the change), # 1 if any version has conflicts or failed. set -euo pipefail cd "$(git rev-parse --show-toplevel)" from="" to="" commit="" dry_run=false while [ $# -gt 0 ]; do case "$1" in --from) from="$2"; shift 2 ;; --to) to="$2"; shift 2 ;; --commit) commit="$2"; shift 2 ;; --dry-run) dry_run=true; shift ;; -h|--help) sed -n '2,23p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; *) echo "Unknown option: $1 (see --help)" >&2; exit 2 ;; esac done # Versions sharing the Stonecutter src/, and the one that stands for them. stonecutter_versions=$(sed -n 's/^val stonecutterVersions = listOf(\(.*\))/\1/p' settings.gradle.kts | tr -d '" ' | tr ',' ' ') stonecutter_active=$(sed -n 's/^stonecutter active "\(.*\)"/\1/p' stonecutter.gradle.kts 2>/dev/null || true) is_stonecutter() { printf '%s\n' $stonecutter_versions | grep -qx "$1" } # Where a version's source lives. src_of() { if is_stonecutter "$1"; then echo "src"; else echo "versions/$1/src"; fi } old_versions=$(for d in versions/*/; do [ -d "$d/src" ] && basename "$d"; done) all_versions=$(printf '%s\n' $old_versions $stonecutter_active | grep . | sort -t. -k1,1n -k2,2n -k3,3n) version_exists() { [ -d "$(src_of "$1")" ] } # Versions with changes, from the working tree or from the given commit. changed_versions() { if [ -n "$commit" ]; then git show --name-only --format= "$commit" -- versions/ src/ else git diff HEAD --name-only -- versions/ src/ git ls-files --others --exclude-standard -- versions/ src/ fi | sed -n -e 's#^versions/\([^/]*\)/src/.*#\1#p' -e "s#^src/.*#${stonecutter_active}#p" | sort -u } if [ -z "$from" ]; then candidates=$(changed_versions) count=$(printf '%s\n' "$candidates" | grep -c . || true) if [ "$count" -ne 1 ]; then echo "Changes found in ${count} versions (${candidates//$'\n'/, }); pass --from to choose one." >&2 exit 2 fi from="$candidates" fi version_exists "$from" || { echo "No such version: $from" >&2; exit 2; } if is_stonecutter "$from"; then from="$stonecutter_active" fi from_src=$(src_of "$from") if [ -z "$to" ]; then to=$(printf '%s\n' $all_versions | grep -vx "$from" | paste -sd, -) fi # New files are only included in `git diff HEAD` once git knows about them. if [ -z "$commit" ]; then untracked=$(git ls-files --others --exclude-standard -- "$from_src") if [ -n "$untracked" ]; then if $dry_run; then echo "note: new files in $from_src are untracked and won't be ported in a dry run:" >&2 printf ' %s\n' $untracked >&2 else printf '%s\n' "$untracked" | xargs git add --intent-to-add -- fi fi fi # --full-index gives --3way the real blob ids to merge against. if [ -n "$commit" ]; then patch=$(git show --full-index --binary --format= "$commit" -- "$from_src") else patch=$(git diff HEAD --full-index --binary -- "$from_src") fi if [ -z "$patch" ]; then echo "No changes under $from_src to port." >&2 exit 2 fi files=$(printf '%s\n' "$patch" | grep -c '^diff --git' || true) echo "Porting $files file(s) from $from${commit:+ (commit $commit)}" from_re=$(printf '%s' "$from_src" | sed 's/\./\\./g') tmp=$(mktemp -d) trap 'rm -rf "$tmp"' EXIT status=0 IFS=',' read -r -a targets <<< "$to" for target in "${targets[@]}"; do if [ "$target" = "$from" ]; then continue fi if is_stonecutter "$target" && [ "$target" != "$stonecutter_active" ]; then printf ' %-8s skipped: shares src/ with %s\n' "$target" "$stonecutter_active" [ "$from" = "$stonecutter_active" ] || status=1 continue fi if ! version_exists "$target"; then printf ' %-8s skipped: no such version\n' "$target" status=1 continue fi # Point the patch's file headers at the target version's source folder. target_src=$(src_of "$target") printf '%s\n' "$patch" | sed -E "/^(diff --git |--- |\+\+\+ |rename (from|to) |copy (from|to) )/ s#([ab]/|(from|to) )${from_re}/#\1${target_src}/#g" > "$tmp/$target.patch" if git apply --check --reverse "$tmp/$target.patch" 2>/dev/null; then printf ' %-8s already has this change\n' "$target" continue fi if $dry_run; then if git apply --check "$tmp/$target.patch" 2>/dev/null; then printf ' %-8s would apply cleanly\n' "$target" else printf ' %-8s would need a 3-way merge (possible conflicts)\n' "$target" fi continue fi if output=$(git apply --3way "$tmp/$target.patch" 2>&1); then printf ' %-8s applied\n' "$target" else conflicts=$(git diff --name-only --diff-filter=U -- "$target_src") if [ -n "$conflicts" ]; then printf ' %-8s CONFLICTS in:\n' "$target" printf '%s\n' "$conflicts" | sed 's/^/ /' else printf ' %-8s FAILED:\n' "$target" printf '%s\n' "$output" | sed 's/^/ /' fi status=1 fi done if ! $dry_run; then echo echo "Ported changes are staged in the target versions. Resolve any conflicts, then build" echo "the affected versions, e.g. ./gradlew compileJava" fi exit $status