diff --git a/git_sync_two_branches/git_sync_two_branches.sh b/git_sync_two_branches/git_sync_two_branches.sh new file mode 100755 index 00000000..9bfdad93 --- /dev/null +++ b/git_sync_two_branches/git_sync_two_branches.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# WORKSPACE: Path to the workspace directory. +# $gitcmd: Path to git executable. +# $gitdir: Directory containing git repo. +# $gitremote: Remote name where the branches are located. Default: origin. +# $dryrun: If set to anything, the script will not perform any changes. +# $source: Source branch to sync from. +# $target: Target branch to sync to. + +# Want exit on error. +set -e + +# This script will sync two branches in a git repo given the target branch is an ancestor +# of the source branch. If the opposite is detected (source branch is an ancestor of the +# target branch), the script will exit with an error. +# Everything (compare, send changes...) to the "gitremote" remote, unconditionally. + +# Verify everything is set +required="WORKSPACE gitdir gitcmd source target" +for var in $required; do + if [ -z "${!var}" ]; then + echo "Error: ${var} environment variable is not defined. See the script comments." + exit 1 + fi +done + +# Calculate some variables +mydir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +gitdir=${gitdir:-} +gitremote=${gitremote:-origin} +gitcmd=${gitcmd:-git} +source=${source:-master} +target=${target:-master} +dryrun=${dryrun:-} + +# Load some functions +source "${mydir}/lib.sh" + +# Verify that the git directory is valid and that the remote exits. +cd "${gitdir}" + +if ! ${gitcmd} remote | grep -q "^${gitremote}$"; then + echo "Error: ${gitremote} is not a valid remote." + exit 1 +fi + +if ! ${gitcmd} rev-parse --git-dir > /dev/null 2>&1; then + echo "Error: ${gitdir} is not a valid git directory." + exit 1 +fi + +# Get the url of the gitremote remote. +remote_url=$(${gitcmd} config --get "remote.${gitremote}.url") + +# Verify that the source and target branches exist in the remote. +if ! ${gitcmd} ls-remote --exit-code --heads "${gitremote}" "${source}" > /dev/null 2>&1; then + echo "Error: ${source} branch does not exist in ${gitremote} remote (${remote_url})." + exit 1 +fi + +if ! ${gitcmd} ls-remote --exit-code --heads "${gitremote}" "${target}" > /dev/null 2>&1; then + echo "Error: ${target} branch does not exist in ${gitremote} remote (${remote_url})." + exit 1 +fi + +echo "Syncing ${gitremote} remote (${remote_url}): Set ${target} target branch to ${source} source branch..." + +if [ -n "${dryrun}" ]; then + echo "Dry-run enabled, no changes will be applied to the ${target} branch in ${gitremote} (${remote_url})." + dryrun="DRY-RUN: " +fi + +# Ensure that both the source and target branches exist locally, creating them if necessary. +if ! ${gitcmd} show-ref --verify --quiet "refs/heads/${source}"; then + echo "Creating local source branch ${source}..." + "${gitcmd}" fetch --quiet "${gitremote}" "${source}" + "${gitcmd}" branch --quiet --force "${source}" --track "${gitremote}/${source}" +else + echo "Updating local source branch ${source}..." + "${gitcmd}" fetch --quiet "${gitremote}" "${source}:${source}" +fi + +if ! ${gitcmd} show-ref --verify --quiet "refs/heads/${target}"; then + echo "Creating local target branch ${target}..." + "${gitcmd}" fetch --quiet "${gitremote}" "${target}" + "${gitcmd}" branch --quiet --force "${target}" --track "${gitremote}/${target}" +else + echo "Updating local target branch ${target}..." + "${gitcmd}" fetch --quiet "${gitremote}" "${target}:${target}" +fi + +# Let's calculate the commit that will be the HEAD at the end of the process. It's the one in the source branch. +commit_outcome=$(${gitcmd} rev-parse --short=16 "${source}") + +# Verify if both branches are the same. If so, we are done. +if is_ancestor "${source}" "${target}" && is_ancestor "${target}" "${source}"; then + echo "Branches ${source} and ${target} are the same. Nothing to do. Current HEAD: ${commit_outcome}" + exit 0 +fi + +# Verify that the source branch is not an ancestor of the target branch. +if is_ancestor "${source}" "${target}"; then + echo "The target ${target} branch got new commits." + log "Error: target ${target} branch has some unexpected commits, not available in the source ${source} branch. Expected HEAD: ${commit_outcome}." + echo "Please, fix the target ${target} branch manually and try again." + exit 1 +fi + +# We are good to go with any of the remaining cases. + +# Verify that the target branch is an ancestor of the source branch. If so, we can fast-forward. +if is_ancestor "${target}" "${source}"; then + echo "The source ${source} branch got new commits." + log "${dryrun}Fast-forwarding target branch ${target} to source branch ${source} at ${gitremote} remote (${remote_url}). New HEAD: ${commit_outcome}" + "${gitcmd}" fetch --quiet . "${source}:${target}" + # Self-assert that the operation happened. + new_commit=$(${gitcmd} rev-parse --short=16 "${target}") + if [[ "${new_commit}" != "${commit_outcome}" ]]; then + log "Error: fast-forwarding failed. Expected new HEAD: ${commit_outcome}. Actual new HEAD: ${new_commit}." + exit 1 + fi + if [[ -z "${dryrun}" ]]; then + "${gitcmd}" push "${gitremote}" --quiet "${target}" + fi + exit 0 +fi + +# Arrived here, the source and target branches have diverged. We'll need to do a +# hard reset of the target branch to the source branch. +echo "Diverged branches (surely because of some rewrite in ${source})." +log "${dryrun}Hard-resetting target branch ${target} to source branch ${source} at ${gitremote} remote (${remote_url}). New HEAD: ${commit_outcome}" +"${gitcmd}" branch --quiet --force "${target}" "${source}" +# Self-assert that the operation happened. +new_commit=$(${gitcmd} rev-parse --short=16 "${target}") +if [[ "${new_commit}" != "${commit_outcome}" ]]; then + log "Error: hard-resetting failed. Expected new HEAD: ${commit_outcome}. Actual new HEAD: ${new_commit}." + exit 1 +fi +if [[ -z "${dryrun}" ]]; then + "${gitcmd}" push "${gitremote}" --force --quiet "${target}" +fi +exit 0 diff --git a/git_sync_two_branches/lib.sh b/git_sync_two_branches/lib.sh new file mode 100755 index 00000000..eed4dd57 --- /dev/null +++ b/git_sync_two_branches/lib.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash + +# Functions for git_sync_two_branches.sh + +# Let's go strict (exit on error) +set -e + +# Apply some defaults in case nobody defined them. +BUILD_NUMBER="${BUILD_NUMBER:-0}" +BUILD_TIMESTAMP="$(date +'%Y-%m-%d_%H-%M-%S')" +gitcmd="${gitcmd:-git}" +logfile="${WORKSPACE}/git_sync_two_branches.log" + +# Utility function to check if a branch is an ancestor of another branch. +# Returns 0 if $1 is an ancestor of $2, 1 otherwise. +function is_ancestor() { + local branch1=$1 + local branch2=$2 + if ${gitcmd} merge-base --is-ancestor "${branch1}" "${branch2}"; then + return 0 + else + return 1 + fi +} + +# Utility function to output something both to stdout and to a log file, with some extra information. +function log() { + echo "$1" + echo "$BUILD_NUMBER $BUILD_TIMESTAMP $1" >> "${logfile}" +} diff --git a/tests/3-git_sync_two_branches.bats b/tests/3-git_sync_two_branches.bats new file mode 100755 index 00000000..1deaf0f7 --- /dev/null +++ b/tests/3-git_sync_two_branches.bats @@ -0,0 +1,106 @@ +#!/usr/bin/env bats + +load libs/shared_setup + +setup_file() { + # All these tests need a moodle git clone with a remote available, called "local_ci_tests" + # pointing to the https://git.in.moodle.com/integration/prechecker.git repository. + cd "${gitdir}" + git remote add local_ci_tests https://git.in.moodle.com/integration/prechecker.git + cd $OLDPWD + + # Note that we'll be removing this custom remote at the end of the tests in this file. +} + +teardown_file() { + # Remove the custom remote we added in setup_file. + cd "${gitdir}" + git remote remove local_ci_tests + cd $OLDPWD +} + +setup() { + # Always perform dry runs when testing. We don't want to change the fixture branches. + cd "${gitdir}" + export dryrun=1 + + # Set the rest of env variables needed for the script. + export gitremote=local_ci_tests + + # Let's checkout master, so we test everything without any local checkout of the branches. + git checkout master --quiet + cd $OLDPWD +} + +teardown() { + # Remove the source and target local branches created for the test. + # (all them begin with local_ci_git_sync_ prefix). + cd "${gitdir}" + git branch --list 'local_ci_git_sync_*' | xargs -r git branch -D --quiet + cd $OLDPWD +} + +@test "git_sync_two_branches: Both branches are in sync" { + + export source=local_ci_git_sync_master + export target=local_ci_git_sync_main + + run git_sync_two_branches/git_sync_two_branches.sh + + # Assert result. + assert_success + + assert_output --partial "Syncing local_ci_tests remote" + assert_output --partial "Set ${target} target branch to ${source} source branch..." + assert_output --partial "Dry-run enabled" + assert_output --partial "Creating local source branch ${source}" + assert_output --partial "Creating local target branch ${target}" + assert_output --partial "Branches ${source} and ${target} are the same" + assert_output --partial "Current HEAD: d76e211be6ae65fe" +} + +@test "git_sync_two_branches: Both branches have diverged (reset)" { + + export source=local_ci_git_sync_master_diverged + export target=local_ci_git_sync_main + + run git_sync_two_branches/git_sync_two_branches.sh + + # Assert result. + assert_success + + assert_output --partial "Diverged branches (surely because of some rewrite" + assert_output --partial "Hard-resetting target branch ${target} to source branch ${source}" + assert_output --partial "New HEAD: 150d134fa2d519cd" +} + +@test "git_sync_two_branches: There are new commits in source branch (fast forward)" { + + export source=local_ci_git_sync_master_fast_forward + export target=local_ci_git_sync_main + + run git_sync_two_branches/git_sync_two_branches.sh + + # Assert result. + assert_success + + assert_output --partial "The source ${source} branch got new commits." + assert_output --partial "Fast-forwarding target branch ${target} to source branch ${source}" + assert_output --partial "New HEAD: 5f3a55f6f01142d8" +} + +@test "git_sync_two_branches: There are new commits in target branch (error)" { + + export source=local_ci_git_sync_master + export target=local_ci_git_sync_main_advanced + + run git_sync_two_branches/git_sync_two_branches.sh + + # Assert result. + assert_failure + + assert_output --partial "The target ${target} branch got new commits." + assert_output --partial "Error: target ${target} branch has some unexpected commits, not available in the source" + assert_output --partial "fix the target ${target} branch manually" + assert_output --partial "Expected HEAD: d76e211be6ae65fe" +}