#!/bin/sh
# $Id$

#
# Located: http://svn.collab.net/repos/svn/trunk/contrib/client-side/svnmerge
#
# Copyright (c) 2004, Awarix, Inc.
# All rights reserved.
# 
# Subject to the following obligations and disclaimer of warranty,
# use and redistribution of this software, in source or object code
# forms, with or without modifications are expressly permitted by
# Awarix; provided, however, that:
# 
#    (i)  Any and all reproductions of the source or object code
#         must include the copyright notice above and the following
#         disclaimer of warranties; and
#    (ii) No rights are granted, in any manner or form, to use
#         Awarix trademarks, including the mark "AWARIX"
#         on advertising, endorsements, or otherwise except as such
#         appears in the above copyright notice or in the software.
# 
# THIS SOFTWARE IS BEING PROVIDED BY AWARIX "AS IS", AND
# TO THE MAXIMUM EXTENT PERMITTED BY LAW, AWARIX MAKES NO
# REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED, REGARDING
# THIS SOFTWARE, INCLUDING WITHOUT LIMITATION, ANY AND ALL IMPLIED
# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE,
# OR NON-INFRINGEMENT.  AWARIX DOES NOT WARRANT, GUARANTEE,
# OR MAKE ANY REPRESENTATIONS REGARDING THE USE OF, OR THE RESULTS
# OF THE USE OF THIS SOFTWARE IN TERMS OF ITS CORRECTNESS, ACCURACY,
# RELIABILITY OR OTHERWISE.  IN NO EVENT SHALL AWARIX BE
# LIABLE FOR ANY DAMAGES RESULTING FROM OR ARISING OUT OF ANY USE
# OF THIS SOFTWARE, INCLUDING WITHOUT LIMITATION, ANY DIRECT,
# INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, PUNITIVE, OR CONSEQUENTIAL
# DAMAGES, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES, LOSS OF
# USE, DATA OR PROFITS, HOWEVER CAUSED AND UNDER ANY THEORY OF
# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
# THE USE OF THIS SOFTWARE, EVEN IF AWARIX IS ADVISED OF
# THE POSSIBILITY OF SUCH DAMAGE.
#
# Author: Archie Cobbs  archie @ awarix dot com

# Definitions (would like ':' in property names but can't because of bug 1971)
NAME="svnmerge"
SVN_MERGE_SVN="svn"
DFLABEL="default"
SRCREV=`echo '$Rev$' | sed 's/^\$Rev: \([0-9]\{1,\}\).\{0,\}$/\1/g'`
SRCDATE=`echo '$Date$' | sed 's/^\$Date: .\{0,\}(\(.\{0,\}\)).\{0,\}$/\1/g'`

# Subroutine to output usage message
usage()
{
    echo 'Usage:'
    echo "  ${NAME} init [-s] [-v] [-n] [-r revs] [-L label] [-f file] head"
    echo '       Initialize merge tracking from "head" on the current working'
    echo '       directory. "head" is either an URL or a working directory;'
    echo '       in the latter case, the corresponding URL is used. "revs"'
    echo '       specifies the already-merged in revisions; it defaults to'
    echo '       "1-HEAD", where HEAD is the latest revision of "head".'
    echo ''
    echo "  ${NAME} avail [-s] [-v] [-l] [-d] [-r revs] [-L label] [branch-dir]"
    echo '       Show unmerged revisions available for "branch-dir" as a'
    echo '       revision list. If revision list "revs" is given, the revisions'
    echo '       shown will be limited to those also specified in "revs".'
    echo '       Options specific to this command:'
    echo '         -l  Show corresponding log history instead of revision list'
    echo '         -d  Show corresponding diffs instead of revision list'
    echo ''
    echo "  ${NAME} merge [-s] [-v] [-n] [-r revs] [-f file] [-L label] [branch-dir]"
    echo '       Merge in revisions specified by "revs" into "branch-dir"'
    echo '       from the "head" location previously specified by init.'
    echo '       "revs" is the revision list specifying revisions to merge in.'
    echo '       Already merged-in revisions will not be merged in again.'
    echo '       Default for "revs" is "1-HEAD" where HEAD is the latest'
    echo '       revision of the "head" repository (i.e., merge all available).'
    echo ''
    echo '  Options common to multiple commands:'
    echo '         -v  Verbose mode: output more information about progress'
    echo '         -s  Show subversion commands that make changes'
    echo "         -n  Don't actually change anything, just pretend; implies -s"
    echo '         -f  Write a suitable commit log message into "file"'
    echo '         -r  Specify a revision list, consisting of revision numbers'
    echo '             and ranges separated by commas, e.g., "534,537-539,540"'
    echo '         -L  Specify a label for merging; allows tracking of merges'
    echo '             from multiple head locations by using unique labels.'
    echo '             Also required when "head" is itself a target for merge'
    echo "             tracking, to avoid ${NAME} property name conflicts."
    echo ''
    echo '   "branch-dir" is always a working directory and defaults to ".".'
    echo "   Default label is \"${DFLABEL}\" for \"init\", otherwise whichever"
    echo '   label is already in use on "branch-dir" if there is only one.'
    echo "   This is svnmerge revision ${SRCREV} dated ${SRCDATE}."
    echo ''
    exit 1
}

# Subroutine to output an error and bail
error()
{
    echo ${NAME}: ${1+"$@"}
    exit 1
}

# Subroutine to output progress message, unless in quiet mode
report()
{
    if [ "${SVN_MERGE_VERBOSE}" != "" ]; then
        echo ${NAME}: ${1+"$@"}
    fi
}

# Subroutine to output an error, usage, and bail
usage_error()
{
    echo ${NAME}: ${1+"$@"}
    usage
}

# Subroutine to do (or pretend to do) an SVN command
svn_command()
{
    if [ "${SVN_MERGE_SHOW_CMDS}" != "" ]; then
        echo "${SVN_MERGE_SVN}" ${1+"$@"}
    fi
    if [ "${SVN_MERGE_PRETEND}" = "" ]; then
        "${SVN_MERGE_SVN}" ${1+"$@"}
        if [ $? -ne 0 ]; then
            error command failed: ${1+"$@"}
        fi
    fi
}

# Check the current status of ${BRANCH_DIR} for up-to-dateness and local mods
check_branch_dir()
{
    report "checking status of \"${BRANCH_DIR}\""
    "${SVN_MERGE_SVN}" status -u "${BRANCH_DIR}" | grep -q '^.......\*' && \
        error "\"${BRANCH_DIR}\" is not up to date; please \"svn update\" first"
    [ `"${SVN_MERGE_SVN}" stat -q "${BRANCH_DIR}" | wc -l` = "0" ] || \
        error "\"${BRANCH_DIR}\" has local modifications; it must be clean"
}

# Subroutine to determine the first revision of a file/directory,
# not including copies. Return the result in ${BRANCH_POINT}
branch_point()
{
    RETURN_VALUE=`"${SVN_MERGE_SVN}" log --xml --stop-on-copy "$1" \
      | tr '\n' ' ' \
      | sed \
        's/^.*\(<logentry[^>]\{1,\}revision="\([0-9]\{1,\}\)"\)\{1,\}.*$/\2/g'`
}

# Subroutine to clean up an URL or path
normalize_url()
{
    TEMP="$1"
    while true; do
        TEMP2=`echo "${TEMP}" | sed -e 's/$/\//g' \
          -e 's/\/[^/]\{1,\}\/\.\.\//\//g' -e 's/\/\.\//\//g' \
          -e 's/\([^:/]\)\/\//\1\//g' -e 's/\/$//g'`
        [ "${TEMP2}" != "${TEMP}" ] || break
        TEMP="${TEMP2}"
    done
    RETURN_VALUE="${TEMP}"
}

# Subroutine to parse out the start and end from a range like "123-456"
get_start_end()
{
    START=`echo "$1" | sed 's/^\([0-9]\{1,\}\)-\([0-9]\{1,\}\)$/\1/g'`
    END=`echo "$1" | sed 's/^\([0-9]\{1,\}\)-\([0-9]\{1,\}\)$/\2/g'`
}

# Subroutine to retrieve an SVN property
get_prop()
{
    # Verify property exists
    "${SVN_MERGE_SVN}" proplist "$2" | grep -q "$1" || \
        error property \"$1\" does not exist on \"$2\"

    # Retrieve property
    RETURN_VALUE=`"${SVN_MERGE_SVN}" propget "$1" "$2"`
}

# Subroutine to parse, validate, and normalize a revision list.
# This input has commas separating ranges and any additional whitespace.
# The result has the form "123-123,125-127,128-130,132-132", i.e.,
# sorted with all adjacent, empty, and redundant ranges merged.
normalize_list()
{
    # Special case empty list
    TEMP=`echo "$1" | tr -d '[:space:]'`
    if [ "${TEMP}" = "" ]; then
        RETURN_VALUE=""
        return 0
    fi

    # See if list is well formed
    NUMPAT='[0-9]\{1,\}'
    RNGPAT="${NUMPAT}\(-${NUMPAT}\)\{0,1\}"
    LISTPAT="\(,\{0,1\}${RNGPAT},\{0,1\}\)\{0,\}"
    expr "${TEMP}" : "${LISTPAT}\$" >/dev/null || \
        usage_error invalid revision list \"$1\"

    # Now sort the list and compress out redundancies
    RESULT=''
    LAST_START=''
    LAST_END=''
    for RNG in `echo "${TEMP}" | tr , '\n' | sort -n -t - -k 1,2 \
      | sed 's/^\([0-9]\{1,\}\)$/\1-\1/g'`; do

        # Get range start and end
        get_start_end "${RNG}"

        # First revision is #1
        if [ "${START}" -le 0 ]; then
            START="1"
        fi

        # Completely ignore any empty ranges
        if [ "${START}" -gt "${END}" ]; then
            continue
        fi

        # First iteration?
        if [ "${LAST_START}" = "" ]; then
            LAST_START=${START}
            LAST_END=${END}
            continue
        fi

        # Does this range overlap with the previous?
        if [ "${START}" -le `expr "${LAST_END}" + 1` ]; then
            if [ "${END}" -gt "${LAST_END}" ]; then
                LAST_END=${END}
            fi
            continue
        fi

        # Break off discontigous range
        [ "${RESULT}" = "" ] || RESULT="${RESULT},"
        RESULT="${RESULT}${LAST_START}-${LAST_END}"
        LAST_START=${START}
        LAST_END=${END}
    done

    # Tack on final range
    if [ "${LAST_START}" != "" ]; then
        [ "${RESULT}" = "" ] || RESULT="${RESULT},"
        RESULT="${RESULT}${LAST_START}-${LAST_END}"
    fi

    # Done
    RETURN_VALUE="${RESULT}"
}

# Subroutine to compute the set $1 minus $2, where $1 and $2 are
# *normalized* revision lists. This is also pretty gross.
list_subtract()
{
    TEMP=''
    for ARNG in `echo $1 | tr ',' ' '`; do

        # Parse range
        get_start_end "${ARNG}"
        ASTART="${START}"
        AEND="${END}"

        # Iterate over subtracted ranges
        for BRNG in `echo $2 | tr ',' ' '`; do

            # Parse range
            get_start_end "${BRNG}"
            BSTART="${START}"
            BEND="${END}"

            # Is this BRNG entirely before or past ARNG?
            if [ ${ASTART} -gt ${BEND} ]; then
                continue
            elif [ ${BSTART} -gt ${AEND} ]; then
                break
            fi

            # Keep the initial part of ARNG missed by BRNG (if anything)
            [ "${TEMP}" = "" ] || TEMP="${TEMP},"
            TEMP="${TEMP}${ASTART}-`expr ${BSTART} - 1`"

            # Keep going with whatever remains of ARNG (if anything)
            if [ ${AEND} -gt ${BEND} ]; then
                ASTART=`expr ${BEND} + 1`
            else
                AEND=`expr ${ASTART} - 1`
                break
            fi
        done

        # Keep what's left of ARNG (if anything)
        [ "${TEMP}" = "" ] || TEMP="${TEMP},"
        TEMP="${TEMP}${ASTART}-${AEND}"
    done

    # Normalize the result
    normalize_list "${TEMP}"
}

# Subroutine to return a normalized list to a more pleasant form
beautify_list()
{
    TEMP=''
    for RNG in `echo "$1" | tr ',' ' '`; do
        get_start_end "${RNG}"
        [ "${TEMP}" = "" ] || TEMP="${TEMP},"
        TEMP="${TEMP}${START}"
        if [ "${END}" != "${START}" ]; then
            TEMP="${TEMP}-${END}"
        fi
    done
    RETURN_VALUE="${TEMP}"
}

# The "init" action
init()
{
    # Check branch directory
    check_branch_dir

    # Get initial revision list if not explicitly specified
    if [ "${REVS}" = "" ]; then
        REVS="1-${HEAD_REVISION}"
    fi

    # Normalize and beautify ${REVS}
    normalize_list "${REVS}"
    beautify_list "${RETURN_VALUE}"
    REVS="${RETURN_VALUE}"

    report marking "${BRANCH_DIR}" as already containing \
        revisions "${REVS}" of "${HEAD_URL}".

    # Set properties
    svn_command propset -q "${SVN_MERGE_HEAD_PROP}" \
      "${HEAD_URL}" "${BRANCH_DIR}"
    svn_command propset -q "${SVN_MERGE_REVS_PROP}" \
      "${REVS}" "${BRANCH_DIR}"

    # Write out commit message if desired
    if [ "${SVN_MERGE_COMMIT_FILE}" != "" ]; then
        echo Initialized merge tracking via "${NAME}" with revisions \
          "${REVS}" from > "${SVN_MERGE_COMMIT_FILE}"
        echo "${HEAD_URL}" >> "${SVN_MERGE_COMMIT_FILE}"
        report wrote commit message to "${SVN_MERGE_COMMIT_FILE}"
    fi
}

# "avail" action
avail()
{
    # Default --avail display type is "revisions"
    [ "${AVAIL_DISPLAY}" != "" ] || AVAIL_DISPLAY="revisions"

    # Calculate outstanding revisions
    list_subtract "1-${HEAD_REVISION}" "${MERGED_REVS}"
    AVAIL_REVS="${RETURN_VALUE}"

    # Limit to revisions specified by -r (if any)
    if [ "${REVS}" != "" ]; then
        normalize_list "${REVS}"
        list_subtract "1-${HEAD_REVISION}" "${RETURN_VALUE}"
        list_subtract "${AVAIL_REVS}" "${RETURN_VALUE}"
        AVAIL_REVS="${RETURN_VALUE}"
    fi

    # Show them, either numerically, in log format, or as diffs
    case "${AVAIL_DISPLAY}" in
        revisions)
            beautify_list "${AVAIL_REVS}"
            echo "${RETURN_VALUE}"
            ;;
        logs)
            for RNG in `echo "${AVAIL_REVS}" | tr ',' ' ' | tr '-' ':'`; do
                svn_command log --incremental -v -r "${RNG}" "${HEAD_URL}"
            done
            ;;
        diffs)
            for RNG in `echo "${AVAIL_REVS}" | tr ',' ' '`; do
                get_start_end "${RNG}"
                echo ''
                echo "${NAME}: changes in revisions ${RNG} follow"
                echo ''
                # Note: the starting revision number to 'svn diff' is
                # NOT inclusive so we have to subtract one from ${START}.
                svn_command diff -r `expr ${START} - 1`:${END} "${HEAD_URL}"
            done
            ;;
        *)
            error internal error
    esac
}

# "merge" action
merge()
{
    # Check branch directory
    check_branch_dir

    # Default to merging all outstanding revisions
    if [ "${REVS}" = "" ]; then
        REVS="1-${HEAD_REVISION}"
    fi

    # Parse desired merge revisions
    normalize_list "${REVS}"
    REVS="${RETURN_VALUE}"

    # Calculate subset of REVS which is not in MERGED_REVS
    list_subtract "${REVS}" "${MERGED_REVS}"
    REVS="${RETURN_VALUE}"
    beautify_list "${REVS}"
    BREVS="${RETURN_VALUE}"

    # Show what we're doing
    beautify_list "${MERGED_REVS}"
    report "\"${BRANCH_DIR}\" already contains revisions ${RETURN_VALUE}"
    report merging in 'revision(s)' "${BREVS}" from "${HEAD_URL}"

    # Do the merge(s). Note: the starting revision number to 'svn merge'
    # is NOT inclusive so we have to subtract one from ${START}.
    for RNG in `echo "${REVS}" | tr ',' ' '`; do
        get_start_end "${RNG}"
        svn_command merge -r `expr ${START} - 1`:${END} \
          "${HEAD_URL}" "${BRANCH_DIR}"
    done

    # Write out commit message if desired
    if [ "${SVN_MERGE_COMMIT_FILE}" != "" ]; then
        echo "Merged revisions ${BREVS} via ${NAME} from" \
          > "${SVN_MERGE_COMMIT_FILE}"
        echo "${HEAD_URL}" >> "${SVN_MERGE_COMMIT_FILE}"
        report wrote commit message to "${SVN_MERGE_COMMIT_FILE}"
    fi

    # Update list of merged revisions
    normalize_list "${MERGED_REVS},${REVS}"
    beautify_list "${RETURN_VALUE}"
    svn_command propset -q "${SVN_MERGE_REVS_PROP}" \
      "${RETURN_VALUE}" "${BRANCH_DIR}"
}

# Get the desired action, compute getopt flags, and apply defaults
[ $# -ge 1 ] || usage_error no action specified
case "$1" in
    init)
        FLAGS="svnr:f:L:"
        BRANCH_DIR="."
        ;;
    avail)
        FLAGS="svldr:L:"
        BRANCH_DIR="."
        AVAIL_DISPLAY="revisions"
        ;;
    merge)
        FLAGS="svnr:f:L:"
        BRANCH_DIR="."
        ;;
    help)
        usage
        ;;
    -*)
        usage_error "no action specified"
        ;;
    *)
        usage_error "unknown action \"$1\""
        ;;
esac
ACTION="$1"
shift

# Unset variables we don't want to inherit from the environment
unset REVS

# Parse remaining command line
ARGS=`getopt "${FLAGS}" $*`
[ $? = 0 ] || usage
set -- ${ARGS}

for i; do
    case "$i" in
        -f)
            SVN_MERGE_COMMIT_FILE="$2"
            shift; shift
            ;;
        -r)
            REVS="$2"
            shift; shift
            ;;
        -d)
            AVAIL_DISPLAY="diffs"
            shift
            ;;
        -l)
            AVAIL_DISPLAY="logs"
            shift
            ;;
        -v)
            SVN_MERGE_VERBOSE="true"
            shift
            ;;
        -n)
            SVN_MERGE_PRETEND="true"
            SVN_MERGE_SHOW_CMDS="true"
            shift
            ;;
        -s)
            SVN_MERGE_SHOW_CMDS="true"
            shift
            ;;
        -L)
            SVN_MERGE_LABEL="$2"
            shift
            shift
            ;;
        --)
            shift
            break
            ;;
    esac
done

# Now parse the non-flag command line parameters
case "${ACTION}" in
    init)
        case $# in
            1)
                HEAD="$1"
                ;;
            *)
                usage_error wrong number of parameters
        esac
        ;;
    avail)
        case $# in
            1)
                BRANCH_DIR="$1"
                ;;
            0)
                ;;
            *)
                usage_error wrong number of parameters
        esac
        ;;
    merge)
        case $# in
            1)
                BRANCH_DIR="$1"
                ;;
            0)
                ;;
            *)
                usage_error wrong number of parameters
        esac
        ;;
esac

# Validate branch-dir
[ -d "${BRANCH_DIR}" -a -d "${BRANCH_DIR}/.svn" ] || \
    error \"${BRANCH_DIR}\" is not a subversion working directory

# Normalize ${BRANCH_DIR}
normalize_url "${BRANCH_DIR}"
BRANCH_DIR="${RETURN_VALUE}"

# See if we need to "upgrade" existing property names
if "${SVN_MERGE_SVN}" proplist "${BRANCH_DIR}" | grep -qw svnmerge-head; then
    echo "${NAME}: old property names detected; an upgrade is required."
    echo ''
    echo 'Please execute and commit these changes to upgrade:'
    echo ''
    echo "  svn propset ${NAME}-${DFLABEL}-head \`svn propget svnmerge-head\` ${BRANCH_DIR}"
    echo "  svn propset ${NAME}-${DFLABEL}-revs \`svn propget svnmerge-revs\` ${BRANCH_DIR}"
    echo "  svn propdel svnmerge-head ${BRANCH_DIR}"
    echo "  svn propdel svnmerge-revs ${BRANCH_DIR}"
    echo ''
    exit 1
fi

# In the --init case, convert ${HEAD} into ${HEAD_URL}
if [ "${ACTION}" = "init" ]; then
    if [ -d "${HEAD}" -a -d "${HEAD}/.svn" ]; then
        HEAD_URL=`"${SVN_MERGE_SVN}" info "${HEAD}" \
          | grep ^URL: | sed -e 's/^URL: \(.*\)$/\1/g'`
    else
        HEAD_URL="${HEAD}"
    fi
fi

# Determine which label to use
if [ "${ACTION}" = "init" ]; then
    [ "${SVN_MERGE_LABEL}" != "" ] || SVN_MERGE_LABEL="${DFLABEL}"
else
    # Get the list of labels currently in use
    LABELS=`"${SVN_MERGE_SVN}" proplist "${BRANCH_DIR}" | tail +2 \
      | grep '^[[:space:]]\{0,\}'"${NAME}"'-\([^-]\{1,\}\)-.\{1,\}$' \
      | sed 's/^[[:space:]]\{0,\}'"${NAME}"'-\([^-]\{1,\}\)-.\{1,\}$/\1/g' \
      | sort -u`
    # If more than one is in use, user must specify which one to use
    # Otherwise, use the only one there is
    NUM_LABELS=`echo ${LABELS} | wc -w`
    if [ ${NUM_LABELS} -eq 1 ]; then
        if [ "${SVN_MERGE_LABEL}" = "" ]; then
            SVN_MERGE_LABEL="${LABELS}"
        fi
    fi
    if [ ${NUM_LABELS} -gt 1 ]; then
        if [ "${SVN_MERGE_LABEL}" = "" ]; then
            echo "${NAME}: multiple labels are in use; -L flag is required."
            echo -n "${NAME}: these labels are currently in use:"
            for LABEL in ${LABELS}; do echo -n " ${LABEL}"; done
            echo ''
            exit 1
        fi
    fi
fi

# Get property names associated with the chosen label
SVN_MERGE_HEAD_PROP="${NAME}-${SVN_MERGE_LABEL}-head"
SVN_MERGE_REVS_PROP="${NAME}-${SVN_MERGE_LABEL}-revs"

# If already initialized, retrieve ${HEAD_URL} from the corresponding property
if [ "${ACTION}" != "init" ]; then
    get_prop "${SVN_MERGE_HEAD_PROP}" "${BRANCH_DIR}"
    HEAD_URL="${RETURN_VALUE}"
fi

# Sanity check ${HEAD_URL}
echo "${HEAD_URL}" | grep -qE '^[[:alpha:]][-+.[:alnum:]]*://' ||
    error "\"${HEAD_URL}\" is not a valid URL or working directory"

# Normalize head URL
normalize_url "${HEAD_URL}"
HEAD_URL="${RETURN_VALUE}"

# Get previously merged revisions (except when --init)
if [ "${ACTION}" != "init" ]; then
    get_prop "${SVN_MERGE_REVS_PROP}" "${BRANCH_DIR}"
    normalize_list "${RETURN_VALUE}"
    MERGED_REVS="${RETURN_VALUE}"
fi

# Get latest revision of head
report checking latest revision of "${HEAD_URL}"
HEAD_REVISION=`"${SVN_MERGE_SVN}" proplist --revprop -r HEAD "${HEAD_URL}" \
  | grep '^Unversioned properties on revision' \
  | sed 's/^Unversioned properties on revision \([0-9]\{1,\}\).*$/\1/g'`
if ! expr "${HEAD_REVISION}" : '[0-9]\{1,\}$' >/dev/null; then
    error "can't get head revision of \"${HEAD_URL}\" (got \"${REVISION}\")"
fi
report latest revision of "${HEAD_URL}" is "${HEAD_REVISION}"

# Perform action
${ACTION}

