From 61d02465cb13e7070728f29bbd74f4f3eca4e90b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20K=C4=99pie=C5=84?= Date: Tue, 27 Feb 2018 10:22:44 +0100 Subject: [PATCH] Add util/git-replay-merge.sh git-replay-merge.sh is a script whose purpose is to make backporting merge requests more convenient by automating the process as much as possible. For more information, including usage examples, see: https://gitlab.isc.org/isc-projects/bind9/wikis/Backporting-a-Merge-Request (cherry picked from commit f7fe1e30987ae659d524da4600f79960af7f2259) --- util/git-replay-merge.sh | 210 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 util/git-replay-merge.sh diff --git a/util/git-replay-merge.sh b/util/git-replay-merge.sh new file mode 100644 index 0000000000..ec8180c850 --- /dev/null +++ b/util/git-replay-merge.sh @@ -0,0 +1,210 @@ +#!/bin/bash +# +# Copyright (C) Internet Systems Consortium, Inc. ("ISC") +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# See the COPYRIGHT file distributed with this work for additional +# information regarding copyright ownership. + +set -e + +SELF="$(basename $0)" +SELF="${SELF/-/ }" + +STATE_FILE=".git/REPLAY_MERGE" + +die() { + for MESSAGE in "$@"; do + echo -e "${MESSAGE}" >&2 + done + exit 1 +} + +die_with_usage() { + die "Usage:" \ + "" \ + " ${SELF} " \ + " ${SELF} --continue" \ + " ${SELF} --abort" +} + +die_with_continue_instructions() { + die "" \ + "Replay interrupted. Conflicts need to be fixed manually." \ + "When done, run \"${SELF} --continue\"." \ + "Use \"${SELF} --abort\" to abort the replay." +} + +die_if_wrong_dir() { + if [[ ! -d ".git" ]]; then + die "You need to run this command from the toplevel of the working tree." + fi +} + +die_if_not_in_progress() { + die_if_wrong_dir + if [[ ! -f "${STATE_FILE}" ]]; then + die "No replay-merge in progress?" + fi +} + +die_if_in_progress() { + die_if_wrong_dir + if [[ -f "${STATE_FILE}" ]]; then + die "Another replay-merge in progress. Use --continue or --abort." + fi +} + +die_if_local_behind_target() { + TARGET_REF_HEAD="$(git rev-list --max-count=1 "${TARGET_REF}")" + if [[ "$(git merge-base "${TARGET_REF}" "${TARGET_BRANCH}")" != "${TARGET_REF_HEAD}" ]]; then + die "Local branch ${TARGET_BRANCH} is behind ${TARGET_REF}, cannot merge into it." \ + "Update or remove the local branch, then run \"${SELF} --continue\"." \ + "Use \"${SELF} --abort\" to abort the replay." + fi +} + +branch_exists() { + ESCAPED_BRANCH_NAME=${1//\//\\\/} + BRANCH_REGEX="/^(remotes\/)?${ESCAPED_BRANCH_NAME}$/" + if [[ -n "$(git branch -a | awk "\$NF ~ ${BRANCH_REGEX} {print \$NF}")" ]]; then + return 0 + else + return 1 + fi +} + +go() { + # Process parameters. + SOURCE_COMMIT="$1" + TARGET_REMOTE="$2" + TARGET_BRANCH="$3" + TARGET_REF="${TARGET_REMOTE}/${TARGET_BRANCH}" + # Establish the range of commits comprising the source branch. + REPLAY_COMMIT_RANGE="$( + git show --format="%P" "${SOURCE_COMMIT}" 2>&1 | + sed -n "1s/\([0-9a-f]\{40\}\) \([0-9a-f]\{40\}\)/\1..\2/p;" + )" + if [[ -z "${REPLAY_COMMIT_RANGE}" ]]; then + die "${SOURCE_COMMIT} is not a valid merge commit ID." + fi + # Extract the name of the source branch. + SOURCE_BRANCH="$( + git log --max-count=1 --format="%B" "${SOURCE_COMMIT}" | + sed -n "s/^Merge branch '\([^'][^']*\).*/\1/p;" | + head -n 1 + )" + if [[ -z "${SOURCE_BRANCH}" ]]; then + die "Unable to extract source branch name from ${SOURCE_COMMIT}." + fi + # Ensure the target ref is valid. + if ! branch_exists "${TARGET_REF}"; then + die "${TARGET_REF} is not a valid replay target." + fi + # Abort if a local branch with the name about to be used for replaying + # the merge already exists. + REPLAY_BRANCH="${SOURCE_BRANCH}-${TARGET_BRANCH}" + if branch_exists "${REPLAY_BRANCH}"; then + die "Local branch with name ${REPLAY_BRANCH} already exists." \ + "Cannot use it for replaying a merge." + fi + # Get the name of the currently checked out branch so that it can be + # checked out again once the replay is finished. + CHECKED_OUT_BRANCH="$(git branch | awk "\$1 == \"*\" {print \$2}")" + # Store state in case it needs to be restored later. + cat <<-EOF > "${STATE_FILE}" + CHECKED_OUT_BRANCH="${CHECKED_OUT_BRANCH}" + SOURCE_COMMIT="${SOURCE_COMMIT}" + SOURCE_BRANCH="${SOURCE_BRANCH}" + REPLAY_BRANCH="${REPLAY_BRANCH}" + TARGET_REMOTE="${TARGET_REMOTE}" + TARGET_BRANCH="${TARGET_BRANCH}" + TARGET_REF="${TARGET_REF}" + EOF + # Announce the plan. + echo "Attempting to replay ${REPLAY_COMMIT_RANGE} on top of ${TARGET_REF} in ${REPLAY_BRANCH}..." + # Switch to the replay branch. + git checkout -t -b "${REPLAY_BRANCH}" "${TARGET_REF}" >/dev/null + # Try replaying the branch. If there is any conflict, the command will + # fail, which means we need to bail and let the user fix the current + # cherry-pick manually, expecting "git replay-merge --continue" to be + # used afterwards. If there is no conflict, just proceed with what + # --continue would do. + if ! git cherry-pick -x "${REPLAY_COMMIT_RANGE}"; then + die_with_continue_instructions + fi + resume +} + +resume() { + # If cherry-picking has not yet been completed, resume it. If it + # fails, bail. If if succeeds, we can proceed with merging. + if [[ -f ".git/sequencer/todo" ]]; then + if ! git cherry-pick --continue; then + die_with_continue_instructions + fi + fi + # Announce the plan. + echo "Attempting to merge ${REPLAY_BRANCH} into ${TARGET_BRANCH}..." + # Check if a local branch with the same name as the target branch + # exists. If it does not, switch to a new local branch with the same + # name as the target branch. Otherwise, ensure the local branch is not + # behind the target branch at the target remote, then switch to it. + if ! branch_exists "${TARGET_BRANCH}"; then + git checkout -t -b "${TARGET_BRANCH}" "${TARGET_REF}" >/dev/null + else + die_if_local_behind_target + git checkout "${TARGET_BRANCH}" &>/dev/null + fi + # Use the original commit message with a modified subject line. + COMMIT_MSG="$( + git log --max-count=1 --format="%B" "${SOURCE_COMMIT}" | + sed "1s/.*/Merge branch '${REPLAY_BRANCH}' into '${TARGET_BRANCH}'/;" + )" + # Merge the replay branch into the local target branch. + git merge --no-ff -m "${COMMIT_MSG}" "${REPLAY_BRANCH}" + cat <<-EOF + + Replayed ${SOURCE_BRANCH} onto ${TARGET_BRANCH}. + To push the replay, use: + + git push ${TARGET_REMOTE} ${TARGET_BRANCH}:${TARGET_BRANCH} + + EOF + cleanup +} + +cleanup() { + # Restore working copy state from before the replay was started, + # ignoring any potential errors to prevent "set -e" from interfering. + { + git merge --abort + git cherry-pick --abort + git checkout "${CHECKED_OUT_BRANCH}" + git branch -D "${REPLAY_BRANCH}" + } &>/dev/null || true + rm -f "${STATE_FILE}" +} + +case "$1" in + "--abort") + die_if_not_in_progress + source "${STATE_FILE}" + cleanup + ;; + "--continue") + die_if_not_in_progress + source "${STATE_FILE}" + resume + ;; + *) + if [[ $# -ne 3 ]]; then + die_with_usage + fi + die_if_in_progress + go "$@" + ;; +esac