diff mbox series

[1/3] vimdiff: new implementation with layout support

Message ID 20211104160959.183402-2-greenfoo@u92.eu (mailing list archive)
State Superseded
Headers show
Series vimdiff: new layout option + docs | expand

Commit Message

Fernando Ramos Nov. 4, 2021, 4:09 p.m. UTC
When running 'git mergetool -t vimdiff', a new configuration option
('mergetool.vimdiff.layout') can now be used to select how the user
wants the different windows, tabs and buffers to be displayed.

If the option is not provided, the layout will be the same one that was
being used before this commit (ie. two rows with LOCAL, BASE and COMMIT
in the top one and MERGED in the bottom one).

The 'vimdiff' variants ('vimdiff{1,2,3}') still work but, because they
represented nothing else than different layouts, are now internally
implemented as a subcase of 'vimdiff' with the corresponding
pre-configured 'layout'.

Signed-off-by: Fernando Ramos <greenfoo@u92.eu>
---
 mergetools/vimdiff | 530 ++++++++++++++++++++++++++++++++++++++++++---
 1 file changed, 504 insertions(+), 26 deletions(-)

Comments

David Aguilar Nov. 7, 2021, 10:41 p.m. UTC | #1
This is an early review. We're still discussing the docs but there's a
few things worth mentioning now before we go too far.

On Thu, Nov 4, 2021 at 9:10 AM Fernando Ramos <greenfoo@u92.eu> wrote:
>
> When running 'git mergetool -t vimdiff', a new configuration option
> ('mergetool.vimdiff.layout') can now be used to select how the user
> wants the different windows, tabs and buffers to be displayed.
>
> If the option is not provided, the layout will be the same one that was
> being used before this commit (ie. two rows with LOCAL, BASE and COMMIT
> in the top one and MERGED in the bottom one).
>
> The 'vimdiff' variants ('vimdiff{1,2,3}') still work but, because they
> represented nothing else than different layouts, are now internally
> implemented as a subcase of 'vimdiff' with the corresponding
> pre-configured 'layout'.
>
> Signed-off-by: Fernando Ramos <greenfoo@u92.eu>
> ---
>  mergetools/vimdiff | 530 ++++++++++++++++++++++++++++++++++++++++++---
>  1 file changed, 504 insertions(+), 26 deletions(-)
>
> diff --git a/mergetools/vimdiff b/mergetools/vimdiff
> index 96f6209a04..1f2e88777e 100644
> --- a/mergetools/vimdiff
> +++ b/mergetools/vimdiff
> @@ -1,48 +1,509 @@
> +#!/bin/bash
> +
> +# This script can be run in two different contexts:
> +#
> +#   - From git, when the user invokes the "vimdiff" merge tool. In this context
> +#     this script expects the following environment variables (among others) to
> +#     be defined (which is something "git" takes care of):
> +#
> +#       - $BASE
> +#       - $LOCAL
> +#       - $REMOTE
> +#       - $MERGED
> +#
> +#     In this mode, all this script does is to run the next command:
> +#
> +#         vim -f -c ... $LOCAL $BASE $REMOTE $MERGED
> +#
> +#     ...where the "..." string depends on the value of the
> +#     "mergetool.vimdiff.layout" configuration variable and is used to open vim
> +#     with a certain layout of buffers, windows and tabs.
> +#
> +#   - From the shell, manually. This is only expected to be done by developers
> +#     who are editing this script. When this happens, the script runs a battery
> +#     of unit tests to make sure nothing breaks.
> +#     In this context this script does not expect any particular environment
> +#     variable to be set.
> +#
> +
> +
> +# Set to "true" to print debug messages to stderr
> +DEBUG=false
> +#DEBUG=true


It might be better to omit "DEBUG=false" and call it
GIT_MERGETOOL_VIMDIFF_DEBUG.


> +
> +
> +################################################################################
> +## Internal functions (not meant to be used outside this script)
> +################################################################################
> +
> +debug_print() {
> +       # Send message to stderr if global variable DEBUG is set to "true"
> +
> +       if test "$DEBUG" = "true"
> +       then
> +               >&2 echo "$@";
> +       fi


... and then in here we can just check:

    if test -n "$GIT_MERGETOOL_VIMDIFF_DEBUG"
    then
        ....
    fi

and we won't ever have to edit the script to activate the debug mode
because it'll get inherited from the environment.



> +}
> +
> +
> +gen_cmd_aux() {
> +       # Auxiliary function used from "gen_cmd()".
> +       # Read that other function documentation for more details.
> +
> +       local LAYOUT=$1
> +       local CMD=$2  # This is a second (hidden) argument used for recursion


I believe "local" and other features used in this script are bash-isms.

We have to stick to a strict portable posix shell subset, so there's
some stuff here that will need adjustment for maximal portability.

We can't use any bashisms and we avoid "local" in scripted porcelains.

We also avoid shell arrays and "Substring Expansion" ${parameter:offset:length}.

https://github.com/git/git/blob/master/Documentation/CodingGuidelines#L41

That's going to require some rework of the implementation below to avoid these.


> +
> +       debug_print
> +       debug_print "LAYOUT    : $LAYOUT"
> +       debug_print "CMD       : $CMD"
> +
> +       if test -z "$CMD"
> +       then
> +               CMD="echo" # vim "nop" operator
> +       fi
> +
> +       local start=0
> +       local end=${#LAYOUT}
> +
> +       local nested=0
> +       local nested_min=100
> +
> +
> +       # Step 1:
> +       #
> +       # Increase/decrease "start"/"end" indices respectively to get rid of
> +       # outer parenthesis.
> +       #
> +       # Example:
> +       #
> +       #   - BEFORE: (( LOCAL | BASE ) - MERGED )
> +       #   - AFTER :  ( LOCAL | BASE ) - MERGED
> +
> +       for (( i=$start; i<$end; i++ )); do

Please avoid multiple statements on a single line (the ";" should be a
line break instead).

The for loop is a bash-ism. An alternative might be...

for i in $(seq $start $end)
do
    ...
done

but that is off-by-one because "seq" includes the $end value, so it'll
need to be decremented by 1.



> +               if test "${LAYOUT:$i:1}" = " "
> +               then
> +                       continue
> +               fi

This is going to need rework because we can't use "${LAYOUT:$i:1}".


> +
> +               if test "${LAYOUT:$i:1}" = "("
> +               then
> +                       nested=$(( nested + 1 ))


I mentioned this in the documentation commit as a comment about splitting a
long line, but we do not use Process Substitution <(list) or >(list) either
so that's another reason to break up the pipeline I mentioned in the previous
email.


Arithmetic substitution is something we do use, though, so this would be fine.


> +                       continue
> +               fi
> +
> +               if test "${LAYOUT:$i:1}" = ")"
> +               then
> +                       nested=$(( nested - 1 ))
> +                       continue
> +               fi
> +
> +               if test "$nested" -lt "$nested_min"
> +               then
> +                       nested_min=$nested
> +               fi
> +       done
> +
> +       debug_print "NESTED MIN: $nested_min"
> +
> +       while test "$nested_min" -gt "0"
> +       do
> +               start=$(( start + 1 ))
> +               end=$(( end - 1 ))
> +
> +               start_minus_one=$(( start - 1 ))
> +
> +               while ! test "${LAYOUT:$start_minus_one:1}" = "("
> +               do
> +                       start=$(( start + 1 ))
> +                       start_minus_one=$(( start_minus_one + 1 ))
> +               done
> +
> +               while ! test "${LAYOUT:$end:1}" = ")"
> +               do
> +                       end=$(( end - 1 ))
> +               done
> +
> +               nested_min=$(( nested_min - 1 ))
> +       done
> +
> +        debug_print "CLEAN     : ${LAYOUT:$start:$(( end - start ))}"
> +
> +
> +       # Step 2:
> +       #
> +       # Search for all valid separators (";", "-" or "|") which are *not*
> +       # inside parenthesis. Save the index at which each of them makes the
> +       # first appearance.


I now understand why the parens are helpful. They seem to make the
implementation simpler/possible.

> +
> +       local index_semicolon=""
> +       local index_minus=""
> +       local index_pipe=""

Drop "local".

Semantic names for these might be helpful. "index_new_tab",
"index_vertical_split" and "index_horizontal_split" might be easier to
understand and would be resilient to sugs about using different
tokens.


> +
> +       nested=0
> +       for (( i=$start; i<$end; i++ )); do
> +               if test "${LAYOUT:$i:1}" = " "
> +               then
> +                       continue
> +               fi
> +
> +               if test "${LAYOUT:$i:1}" = "("
> +               then
> +                       nested=$(( nested + 1 ))


Here and below -- should that be nested=$(( $nested + 1 )) ?

It seems to be missing a '$' prefix on the inner $nested variable.

My shell accepts both but the predominant style in the git test suite
is the use $nested, so let's do that.



> +                       continue
> +               fi
> +
> +               if test "${LAYOUT:$i:1}" = ")"
> +               then
> +                       nested=$(( nested - 1 ))
> +                       continue
> +               fi
> +
> +               if test "$nested" -eq "0"
> +               then
> +                       current=${LAYOUT:$i:1}
> +
> +                       if test "$current" = ";"
> +                       then
> +                               if test -z "$index_semicolon"
> +                               then
> +                                       index_semicolon=$i
> +                               fi
> +
> +                       elif test "$current" = "-"
> +                       then
> +                               if test -z "$index_minus"
> +                               then
> +                                       index_minus=$i
> +                               fi
> +
> +                       elif test "$current" = "|"
> +                       then
> +                               if test -z "$index_pipe"
> +                               then
> +                                       index_pipe=$i
> +                               fi
> +                       fi
> +               fi
> +       done
> +
> +
> +       # Step 3:
> +       #
> +       # Process the separator with the highest order of precedence
> +       # (";" has the highest precedence and "|" the lowest one).
> +       #
> +       # By "process" I mean recursively call this function twice: the first
> +       # one with the substring at the left of the separator and the second one
> +       # with the one at its right.
> +
> +       local terminate="false"
> +
> +       if ! test -z "$index_semicolon"
> +       then
> +               before="-tabnew"
> +               after="tabnext"
> +               index=$index_semicolon
> +               terminate="true"
> +
> +       elif ! test -z "$index_minus"
> +       then
> +               before="split"
> +               after="wincmd j"
> +               index=$index_minus
> +               terminate="true"
> +
> +       elif ! test -z "$index_pipe"
> +       then
> +               before="vertical split"
> +               after="wincmd l"
> +               index=$index_pipe
> +               terminate="true"
> +       fi
> +
> +       if  test "$terminate" = "true"
> +       then
> +               CMD="$CMD | $before"
> +               CMD=$(gen_cmd_aux "${LAYOUT:$start:$(( index - start ))}" "$CMD")
> +               CMD="$CMD | $after"
> +               CMD=$(gen_cmd_aux "${LAYOUT:$(( index + 1 )):$(( ${#LAYOUT} - index ))}" "$CMD")
> +               echo $CMD
> +               return
> +       fi
> +
> +
> +       # Step 4:
> +       #
> +       # If we reach this point, it means there are no separators and we just
> +       # need to print the command to display the specified buffer
> +
> +       local target=$(echo "${LAYOUT:$start:$(( end - start ))}" | sed 's:[ *();|-]::g')
> +
> +       if test "$target" = "LOCAL"
> +       then
> +               CMD="$CMD | 1b"
> +
> +       elif test "$target" = "BASE"
> +       then
> +               CMD="$CMD | 2b"
> +
> +       elif test "$target" = "REMOTE"
> +       then
> +               CMD="$CMD | 3b"
> +
> +       elif test "$target" = "MERGED"
> +       then
> +               CMD="$CMD | 4b"
> +
> +       else
> +               CMD="$CMD | ERROR: >$target<"
> +       fi
> +
> +       echo $CMD
> +       return
> +}
> +
> +
> +gen_cmd() {
> +       # This function returns (in global variable FINAL_CMD) the string that
> +       # you can use when invoking "vim" (as shown next) to obtain a given
> +       # layout:
> +       #
> +       #   $ vim -f $FINAL_CMD "$LOCAL" "$BASE" "$REMOTE" "$MERGED"
> +       #
> +       # It takes one single argument: a string containing the desired layout
> +       # definition.
> +       #
> +       # The syntax of the "layout definitions" is explained in ... (TODO)...
> +       # but you can already intuitively understand how it works by knowing
> +       # that...
> +       #
> +       #   * ";" means "a new vim tab"
> +       #   * "-" means "a new vim horizontal split"
> +       #   * "|" means "a new vim vertical split"
> +       #
> +       # It also returns (in global variable FINAL_TARGET) the name ("LOCAL",
> +       # "BASE", "REMOTE" or "MERGED") of the file that is marked with an "*",
> +       # or "MERGED" if none of them is.
> +       #
> +       # Example:
> +       #
> +       #     gen_cmd "LOCAL* | REMOTE"
> +       #     |
> +       #     `-> FINAL_CMD    == "-c \"echo | vertical split | 1b | wincmd l | 3b | tabdo windo diffthis\" -c \"tabfirst\""
> +       #         FINAL_TARGET == "LOCAL"
> +
> +       local LAYOUT=$1
> +
> +
> +       # Search for a "*" in one of the files identifiers ("LOCAL", "BASE",
> +       # "REMOTE", "MERGED"). If not found, use "MERGE" as the default file
> +       # where changes will be saved.
> +
> +       AUX=$(echo "$LAYOUT" | grep -oe "[A-Z]\+\*")


From Documentatin/CodingGuidelines:

As to use of grep, stick to a subset of BRE (namely, no {m,n},
   [::], [==], or [..]) for portability.



> +
> +       if ! test -z "$AUX"
> +       then
> +               FINAL_TARGET="${AUX:0:-1}"
> +       else
> +               FINAL_TARGET="MERGED"
> +       fi


The conditional above is better written as:

    if test -n "$AUX"
    then
        ...
    else
        ...
    fi

"test -n" is the logical opposite of "test -z", so "! test -z" can be
replaced with "test -n".




> +
> +
> +       # Obtain the first part of vim "-c" option to obtain the desired layout
> +
> +       CMD=$(gen_cmd_aux "$LAYOUT")
> +
> +
> +       # Adjust the just obtained script depending on whether more than one
> +       # windows are visible or not
> +
> +       if test $(echo $LAYOUT | wc -w) == "1"
> +       then
> +               CMD="$CMD | bufdo diffthis"
> +        else
> +               CMD="$CMD | tabdo windo diffthis"
> +       fi


The output of "wc -c" is non-portable. It contains leading whitespace
on some platforms.

The test expression should be:

   test "$value" = 1

with a single "=" rather than "==".



> +
> +
> +       # Add an extra "-c" option to move to the first tab (notice that we
> +       # can't simply append the command to the previous "-c" string as
> +       # explained here: https://github.com/vim/vim/issues/9076
> +
> +       FINAL_CMD="-c \"$CMD\" -c \"tabfirst\""
> +}
> +
> +
> +run_unit_tests() {
> +       # Function to make sure that we don't break anything when modifying this
> +       # script.
> +       #
> +       # This function is automatically executed when you execute this script
> +       # from the shell with environment variable "DEBUG_GIT_VIMDIFF" set (to
> +       # any value).
> +
> +       local test_cases=(
> +               `#Test case 00` "LOCAL | MERGED | REMOTE"
> +               `#Test case 01` "LOCAL - MERGED - REMOTE"
> +               `#Test case 02` "(LOCAL - REMOTE) | MERGED"
> +               `#Test case 03` "MERGED | (LOCAL - REMOTE)"
> +               `#Test case 04` "(LOCAL | REMOTE) - MERGED"
> +               `#Test case 05` "MERGED - (LOCAL | REMOTE)"
> +               `#Test case 06` "(LOCAL | BASE | REMOTE) - MERGED"
> +               `#Test case 07` "(LOCAL - BASE - REMOTE) | MERGED"
> +               `#Test case 08` "LOCAL* | REMOTE"
> +               `#Test case 09` "MERGED"
> +               `#Test case 10` "(LOCAL | BASE | REMOTE) - MERGED; BASE | LOCAL; BASE | REMOTE; (LOCAL - BASE - REMOTE) | MERGED"
> +               `#Test case 11` "((LOCAL | REMOTE) - BASE) | MERGED"
> +               `#Test case 12` "((LOCAL | REMOTE) - BASE) | ((LOCAL - REMOTE) | MERGED)"
> +               `#Test case 13` "BASE | REMOTE ; BASE | LOCAL"
> +       )
> +
> +       local expected_cmd=(
> +               `#Test case 00` "-c \"echo | vertical split | 1b | wincmd l | vertical split | 4b | wincmd l | 3b | tabdo windo diffthis\" -c \"tabfirst\""
> +               `#Test case 01` "-c \"echo | split | 1b | wincmd j | split | 4b | wincmd j | 3b | tabdo windo diffthis\" -c \"tabfirst\""
> +               `#Test case 02` "-c \"echo | vertical split | split | 1b | wincmd j | 3b | wincmd l | 4b | tabdo windo diffthis\" -c \"tabfirst\""
> +               `#Test case 03` "-c \"echo | vertical split | 4b | wincmd l | split | 1b | wincmd j | 3b | tabdo windo diffthis\" -c \"tabfirst\""
> +               `#Test case 04` "-c \"echo | split | vertical split | 1b | wincmd l | 3b | wincmd j | 4b | tabdo windo diffthis\" -c \"tabfirst\""
> +               `#Test case 05` "-c \"echo | split | 4b | wincmd j | vertical split | 1b | wincmd l | 3b | tabdo windo diffthis\" -c \"tabfirst\""
> +               `#Test case 06` "-c \"echo | split | vertical split | 1b | wincmd l | vertical split | 2b | wincmd l | 3b | wincmd j | 4b | tabdo windo diffthis\" -c \"tabfirst\""
> +               `#Test case 07` "-c \"echo | vertical split | split | 1b | wincmd j | split | 2b | wincmd j | 3b | wincmd l | 4b | tabdo windo diffthis\" -c \"tabfirst\""
> +               `#Test case 08` "-c \"echo | vertical split | 1b | wincmd l | 3b | tabdo windo diffthis\" -c \"tabfirst\""
> +               `#Test case 09` "-c \"echo | 4b | bufdo diffthis\" -c \"tabfirst\""
> +               `#Test case 10` "-c \"echo | -tabnew | split | vertical split | 1b | wincmd l | vertical split | 2b | wincmd l | 3b | wincmd j | 4b | tabnext | -tabnew | vertical split | 2b | wincmd l | 1b | tabnext | -tabnew | vertical split | 2b | wincmd l | 3b | tabnext | vertical split | split | 1b | wincmd j | split | 2b | wincmd j | 3b | wincmd l | 4b | tabdo windo diffthis\" -c \"tabfirst\""
> +               `#Test case 11` "-c \"echo | vertical split | split | vertical split | 1b | wincmd l | 3b | wincmd j | 2b | wincmd l | 4b | tabdo windo diffthis\" -c \"tabfirst\""
> +               `#Test case 12` "-c \"echo | vertical split | split | vertical split | 1b | wincmd l | 3b | wincmd j | 2b | wincmd l | vertical split | split | 1b | wincmd j | 3b | wincmd l | 4b | tabdo windo diffthis\" -c \"tabfirst\""
> +               `#Test case 13` "-c \"echo | -tabnew | vertical split | 2b | wincmd l | 3b | tabnext | vertical split | 2b | wincmd l | 1b | tabdo windo diffthis\" -c \"tabfirst\""
> +       )
> +
> +       local expected_target=(
> +               `#Test case 00` "MERGED"
> +               `#Test case 01` "MERGED"
> +               `#Test case 02` "MERGED"
> +               `#Test case 03` "MERGED"
> +               `#Test case 04` "MERGED"
> +               `#Test case 05` "MERGED"
> +               `#Test case 06` "MERGED"
> +               `#Test case 07` "MERGED"
> +               `#Test case 08` "LOCAL"
> +               `#Test case 09` "MERGED"
> +               `#Test case 10` "MERGED"
> +               `#Test case 11` "MERGED"
> +               `#Test case 12` "MERGED"
> +               `#Test case 13` "MERGED"
> +       )


We can't use shell arrays. This part really tells me that I don't
understand bash at all.

I don't understand what the backticks are doing.. is it actually
running something?

That's just a rhetorical question.. I don't actually know, but it's okay if we
ignore this question since we already indicated that we'll have to do
w/out arrays.




> +
> +       local at_least_one_ko="false"
> +
> +       for i in ${!test_cases[@]}; do


I suspect ${!test_cases[@]} is some bash-ism that we can't use.

For the final patch, I think it would be good if we had a way to run this
through the test suite if possible rather than needing to run the script
directly.

It might have more leeway to setup the environment for testing that way too.


> +               gen_cmd "${test_cases[$i]}"
> +
> +               if test "$FINAL_CMD" = "${expected_cmd[$i]}" && test "$FINAL_TARGET" = "${expected_target[$i]}"
> +               then
> +                       printf "Test Case #%02d: OK\n" $i
> +               else
> +                       printf "Test Case #%02d: KO !!!!\n" $i
> +                       echo "  FINAL_CMD              : $FINAL_CMD"
> +                        echo "  FINAL_CMD (expected)   : ${expected_cmd[$i]}"
> +                       echo "  FINAL_TARGET           : $FINAL_TARGET"
> +                        echo "  FINAL_TARGET (expected): ${expected_target[$i]}"
> +                       at_least_one_ko="true"
> +               fi
> +       done
> +
> +       if test "$at_least_one_ko" = "true"
> +       then
> +               return -1
> +       else
> +               return 0
> +       fi
> +}
> +
> +
> +################################################################################
> +## API functions (called from "git-mergetool--lib.sh")
> +################################################################################
> +
>  diff_cmd () {
>         "$merge_tool_path" -R -f -d \
>                 -c 'wincmd l' -c 'cd $GIT_PREFIX' "$LOCAL" "$REMOTE"
>  }
>
> +
>  merge_cmd () {
> +       layout=$(git config mergetool.$merge_tool.layout)
> +       print_warning="false"
> +
>         case "$1" in
>         *vimdiff)
> -               if $base_present
> +               if test -z "$layout"
>                 then
> -                       "$merge_tool_path" -f -d -c '4wincmd w | wincmd J' \
> -                               "$LOCAL" "$BASE" "$REMOTE" "$MERGED"
> -               else
> -                       "$merge_tool_path" -f -d -c 'wincmd l' \
> -                               "$LOCAL" "$MERGED" "$REMOTE"
> +                       # Default layout when none is specified
> +                       layout="(LOCAL | BASE | REMOTE) - MERGED"
>                 fi
>                 ;;
>         *vimdiff1)
> -               "$merge_tool_path" -f -d \
> -                       -c 'echon "Resolve conflicts leftward then save. Use :cq to abort."' \
> -                       "$LOCAL" "$REMOTE"
> -               ret="$?"
> -               if test "$ret" -eq 0
> -               then
> -                       cp -- "$LOCAL" "$MERGED"
> -               fi
> -               return "$ret"
> +               layout="LOCAL* | MERGED"
> +               print_warning="true"
>                 ;;
>         *vimdiff2)
> -               "$merge_tool_path" -f -d -c 'wincmd l' \
> -                       "$LOCAL" "$MERGED" "$REMOTE"
> +               layout="LOCAL | MERGED | REMOTE"
> +               print_warning="true"
>                 ;;
>         *vimdiff3)
> -               if $base_present
> -               then
> -                       "$merge_tool_path" -f -d -c 'hid | hid | hid' \
> -                               "$LOCAL" "$REMOTE" "$BASE" "$MERGED"
> -               else
> -                       "$merge_tool_path" -f -d -c 'hid | hid' \
> -                               "$LOCAL" "$REMOTE" "$MERGED"
> -               fi
> +               layout="MERGED"
> +               print_warning="true"
>                 ;;
>         esac
> +
> +       if test "$print_warning" = "true"
> +       then
> +               echo "WARNING:"
> +               echo "WARNING: '$1' is going to be removed in a future version. You will be"
> +               echo "WARNING: able to obtain the same result by selecting 'vimdiff' as the merge"
> +               echo "WARNING: tool and setting configuration variable 'mergetool.vimdiff.layout'"
> +               echo "WARNING: to the following value:"
> +               echo "WARNING:"
> +               echo "WARNING:     layout = \"$layout\""
> +               echo "WARNING:"
> +               echo "Press ENTER to continue..."
> +               read
> +       fi

I wonder if we should ever remove the old variants.

We could just keep them around forever, and then users won't ever be bothered.

The bulk of the improvement here is to improve the implementation so
that we don't
ever have to add any new variants, and to fold the implementations
together into a
common approach so that there's less code to maintain.

It seems like there's really no need to burden users with our
implementation choices.

I personally would be in favor of dropping the deprecation angle and treating
these patches more-so as an refactoring of the implementation.


> +
> +       gen_cmd "$layout"
> +
> +       debug_print ""
> +       debug_print "FINAL CMD : $FINAL_CMD"
> +       debug_print "FINAL TAR : $FINAL_TARGET"
> +
> +       if $base_present
> +       then
> +               eval "$merge_tool_path" \
> +                       -f $FINAL_CMD "$LOCAL" "$BASE" "$REMOTE" "$MERGED"
> +       else
> +               # If there is no BASE (example: a merge conflict in a new file
> +               # with the same name created in both braches which didn't exist
> +               # before), close all BASE windows using vim's "quit" command
> +
> +               FINAL_CMD=$(echo $FINAL_CMD | \
> +                       sed -e 's:2b:quit:g' -e 's:3b:2b:g' -e 's:4b:3b:g')
> +
> +               eval "$merge_tool_path" \
> +                       -f $FINAL_CMD "$LOCAL" "$REMOTE" "$MERGED"
> +       fi
> +
> +
> +       ret="$?"
> +       if test "$ret" -eq 0

This should be:

    if test "$ret" = 0

> +       then
> +               if test "$FINAL_TARGET" != "MERGED"
> +               then
> +                       eval cp -- \$"$FINAL_TARGET" "$MERGED"

This eval may not be safe when the value contains whitespace or shell
metacharacters.

I think it might be better to just spell it out and be explicit.

It's more code but it'll be easier to follow:

case "$FINAL_TARGET" in
LOCAL)
    source_path="$LOCAL"
    ;;
REMOTE)
    source_path="$REMOTE"
    ;;
MERGED|*)
    # Do nothing
    source_path=
    ;;
esac

if test -n "$source_path"
then
    cp -- "$source_path" "$MERGED"
fi



> +               fi
> +       fi
> +       return "$ret"
>  }
>
> +
>  translate_merge_tool_path() {
>         case "$1" in
>         nvimdiff*)
> @@ -57,14 +518,31 @@ translate_merge_tool_path() {
>         esac
>  }
>
> +
>  exit_code_trustable () {
>         true
>  }
>
> +
>  list_tool_variants () {
>         for prefix in '' g n; do
> -               for suffix in '' 1 2 3; do
> +               for suffix in '' 1 2 3
> +               do
>                         echo "${prefix}vimdiff${suffix}"
>                 done
>         done
>  }
> +
> +
> +################################################################################
> +## Run unit tests when calling this script from a shell
> +################################################################################
> +
> +if test $(ps -o stat= -p $PPID) = "Ss" && test $(ps -o stat= -p $$) = "S+"
> +then
> +       # Script is being manually run from command line (see
> +       # https://stackoverflow.com/questions/4261876/check-if-bash-script-was-invoked-from-a-shell-or-another-script-application)
> +
> +       run_unit_tests
> +fi


I'm not 100% sure, but I suspect this is probably non-portable and
will have some issues.

This is another vote for setting something up to go through the test suite.

The test suite can set some GIT_MERGETOOL_VIMDIFF_TESTING variable
that the scriplet
can key off of explicitly rather than having the script embed this stuff in it.

A test could then source the scriptlet directly and exercise functions
provided by it.

If the implementation details get too complex for shell, I wonder if
it's time to split
off a small helper command in C that does the layout -> vim script
translation. TBD.

The parsing code is pretty complex but if there's a way to do it in our portable
posix shell subset then we'll take that. There might be a point where having a
small dedicated helper command might be easier.

You'll probably have the best sense of that, though.

Thanks for diving into making this happen.
Eric Sunshine Nov. 8, 2021, 12:47 a.m. UTC | #2
On Sun, Nov 7, 2021 at 5:42 PM David Aguilar <davvid@gmail.com> wrote:
> On Thu, Nov 4, 2021 at 9:10 AM Fernando Ramos <greenfoo@u92.eu> wrote:
> > +       AUX=$(echo "$LAYOUT" | grep -oe "[A-Z]\+\*")
>
> From Documentatin/CodingGuidelines:
>
> As to use of grep, stick to a subset of BRE (namely, no {m,n},
>    [::], [==], or [..]) for portability.

Also, `grep -o` isn't POSIX.

> > +       if test $(echo $LAYOUT | wc -w) == "1"
> > +       then
> > +               CMD="$CMD | bufdo diffthis"
> > +        else
> > +               CMD="$CMD | tabdo windo diffthis"
> > +       fi
>
> The output of "wc -c" is non-portable. It contains leading whitespace
> on some platforms.
>
> The test expression should be:
>
>    test "$value" = 1
>
> with a single "=" rather than "==".

For clarification, the leading whitespace emitted by some `wc`
implementations is only a problem when encapsulated in a string. For
instance, like this:

    if test "$(... | wc -w)" = "1"

in which case "  1"  won't equal "1". The usage here, however, should
be okay since the output is not quoted.

Quite correct about using "=" (or even "-eq") here rather than "==", though.

> > +       if $base_present
> > +       then
> > +               eval "$merge_tool_path" \
> > +                       -f $FINAL_CMD "$LOCAL" "$BASE" "$REMOTE" "$MERGED"
> > +       else
> > +               [...]
> > +               eval "$merge_tool_path" \
> > +                       -f $FINAL_CMD "$LOCAL" "$REMOTE" "$MERGED"
> > +       fi
> > +
> > +       ret="$?"
> > +       if test "$ret" -eq 0
>
> This should be:
>
>     if test "$ret" = 0

Or simpler, no need for `ret` at all:

    if test $? -eq 0

(or `if test $? = 0` -- either works)

Another (perhaps better) alternative is to assign the result of `eval`
to `ret` at the point of invocation, which lessens the cognitive load
a bit since you don't have to scan backward through the code trying to
figure out what $? refers to.

Also, why is `eval` needed here? Is there something non-obvious going
on? (Genuine question; I didn't trace the code thoroughly to
understand.)

> > +                       eval cp -- \$"$FINAL_TARGET" "$MERGED"
>
> This eval may not be safe when the value contains whitespace or shell
> metacharacters.
>
> I think it might be better to just spell it out and be explicit.
>
> It's more code but it'll be easier to follow:
> [...]
> if test -n "$source_path"
> then
>     cp -- "$source_path" "$MERGED"
> fi

I suspect `--` also needs to be avoided since it is not POSIX.
diff mbox series

Patch

diff --git a/mergetools/vimdiff b/mergetools/vimdiff
index 96f6209a04..1f2e88777e 100644
--- a/mergetools/vimdiff
+++ b/mergetools/vimdiff
@@ -1,48 +1,509 @@ 
+#!/bin/bash
+
+# This script can be run in two different contexts:
+#
+#   - From git, when the user invokes the "vimdiff" merge tool. In this context
+#     this script expects the following environment variables (among others) to
+#     be defined (which is something "git" takes care of):
+#
+#       - $BASE
+#       - $LOCAL
+#       - $REMOTE
+#       - $MERGED
+#
+#     In this mode, all this script does is to run the next command:
+#
+#         vim -f -c ... $LOCAL $BASE $REMOTE $MERGED
+#
+#     ...where the "..." string depends on the value of the
+#     "mergetool.vimdiff.layout" configuration variable and is used to open vim
+#     with a certain layout of buffers, windows and tabs.
+#
+#   - From the shell, manually. This is only expected to be done by developers
+#     who are editing this script. When this happens, the script runs a battery
+#     of unit tests to make sure nothing breaks.
+#     In this context this script does not expect any particular environment
+#     variable to be set.
+#
+
+
+# Set to "true" to print debug messages to stderr
+DEBUG=false
+#DEBUG=true
+
+
+################################################################################
+## Internal functions (not meant to be used outside this script)
+################################################################################
+
+debug_print() { 
+	# Send message to stderr if global variable DEBUG is set to "true"
+        
+	if test "$DEBUG" = "true"
+	then
+		>&2 echo "$@";
+	fi
+}
+
+
+gen_cmd_aux() {
+	# Auxiliary function used from "gen_cmd()".
+	# Read that other function documentation for more details.
+
+	local LAYOUT=$1 
+	local CMD=$2  # This is a second (hidden) argument used for recursion  
+
+	debug_print
+	debug_print "LAYOUT    : $LAYOUT"
+	debug_print "CMD       : $CMD"
+
+	if test -z "$CMD"
+	then
+		CMD="echo" # vim "nop" operator
+	fi
+
+	local start=0
+	local end=${#LAYOUT}
+
+	local nested=0
+	local nested_min=100
+
+
+	# Step 1:
+	#
+	# Increase/decrease "start"/"end" indices respectively to get rid of
+	# outer parenthesis.
+	#
+	# Example:
+	#
+	#   - BEFORE: (( LOCAL | BASE ) - MERGED )
+	#   - AFTER :  ( LOCAL | BASE ) - MERGED 
+
+	for (( i=$start; i<$end; i++ )); do
+		if test "${LAYOUT:$i:1}" = " "
+		then
+			continue
+		fi
+
+		if test "${LAYOUT:$i:1}" = "("
+		then
+			nested=$(( nested + 1 ))
+			continue
+		fi
+
+		if test "${LAYOUT:$i:1}" = ")"
+		then
+			nested=$(( nested - 1 ))
+			continue
+		fi
+
+		if test "$nested" -lt "$nested_min"
+		then
+			nested_min=$nested
+		fi
+	done
+
+	debug_print "NESTED MIN: $nested_min"
+
+	while test "$nested_min" -gt "0"
+	do
+		start=$(( start + 1 ))
+		end=$(( end - 1 ))
+
+		start_minus_one=$(( start - 1 ))
+
+		while ! test "${LAYOUT:$start_minus_one:1}" = "("
+		do
+			start=$(( start + 1 ))
+			start_minus_one=$(( start_minus_one + 1 ))
+		done
+
+		while ! test "${LAYOUT:$end:1}" = ")"
+		do
+			end=$(( end - 1 ))
+		done
+
+		nested_min=$(( nested_min - 1 ))
+	done
+
+        debug_print "CLEAN     : ${LAYOUT:$start:$(( end - start ))}"
+
+
+	# Step 2:
+	#
+	# Search for all valid separators (";", "-" or "|") which are *not*
+	# inside parenthesis. Save the index at which each of them makes the
+	# first appearance.
+
+	local index_semicolon=""
+	local index_minus=""
+	local index_pipe=""
+
+	nested=0
+	for (( i=$start; i<$end; i++ )); do
+		if test "${LAYOUT:$i:1}" = " "
+		then
+			continue
+		fi
+
+		if test "${LAYOUT:$i:1}" = "("
+		then
+			nested=$(( nested + 1 ))
+			continue
+		fi
+
+		if test "${LAYOUT:$i:1}" = ")"
+		then
+			nested=$(( nested - 1 ))
+			continue
+		fi
+
+		if test "$nested" -eq "0"
+		then
+			current=${LAYOUT:$i:1}
+			
+			if test "$current" = ";"
+			then
+				if test -z "$index_semicolon"
+				then
+					index_semicolon=$i
+				fi
+
+			elif test "$current" = "-"
+			then
+				if test -z "$index_minus"
+				then
+					index_minus=$i
+				fi
+
+			elif test "$current" = "|"
+			then
+				if test -z "$index_pipe"
+				then
+					index_pipe=$i
+				fi
+			fi
+		fi
+	done
+
+
+	# Step 3:
+	#
+	# Process the separator with the highest order of precedence
+	# (";" has the highest precedence and "|" the lowest one).
+	#
+	# By "process" I mean recursively call this function twice: the first
+	# one with the substring at the left of the separator and the second one
+	# with the one at its right.
+
+	local terminate="false"
+
+	if ! test -z "$index_semicolon"
+	then
+		before="-tabnew"
+		after="tabnext"
+		index=$index_semicolon
+		terminate="true"
+
+	elif ! test -z "$index_minus"
+	then
+		before="split"
+		after="wincmd j"
+		index=$index_minus
+		terminate="true"
+
+	elif ! test -z "$index_pipe"
+	then
+		before="vertical split"
+		after="wincmd l"
+		index=$index_pipe
+		terminate="true"
+	fi
+
+	if  test "$terminate" = "true"
+	then
+		CMD="$CMD | $before"
+		CMD=$(gen_cmd_aux "${LAYOUT:$start:$(( index - start ))}" "$CMD")
+		CMD="$CMD | $after"
+		CMD=$(gen_cmd_aux "${LAYOUT:$(( index + 1 )):$(( ${#LAYOUT} - index ))}" "$CMD")
+		echo $CMD
+		return
+	fi
+
+
+	# Step 4:
+	#
+	# If we reach this point, it means there are no separators and we just
+	# need to print the command to display the specified buffer
+
+	local target=$(echo "${LAYOUT:$start:$(( end - start ))}" | sed 's:[ *();|-]::g')
+
+	if test "$target" = "LOCAL"
+	then
+		CMD="$CMD | 1b"
+
+	elif test "$target" = "BASE"
+	then
+		CMD="$CMD | 2b"
+
+	elif test "$target" = "REMOTE"
+	then
+		CMD="$CMD | 3b"
+
+	elif test "$target" = "MERGED"
+	then
+		CMD="$CMD | 4b"
+
+	else
+		CMD="$CMD | ERROR: >$target<"
+	fi
+
+	echo $CMD
+	return
+}
+
+
+gen_cmd() {
+	# This function returns (in global variable FINAL_CMD) the string that
+	# you can use when invoking "vim" (as shown next) to obtain a given
+	# layout:
+	#
+	#   $ vim -f $FINAL_CMD "$LOCAL" "$BASE" "$REMOTE" "$MERGED"
+	#
+	# It takes one single argument: a string containing the desired layout
+	# definition.
+	#
+	# The syntax of the "layout definitions" is explained in ... (TODO)...
+	# but you can already intuitively understand how it works by knowing
+	# that...
+	#
+	#   * ";" means "a new vim tab"
+	#   * "-" means "a new vim horizontal split"
+	#   * "|" means "a new vim vertical split"
+	#
+	# It also returns (in global variable FINAL_TARGET) the name ("LOCAL",
+	# "BASE", "REMOTE" or "MERGED") of the file that is marked with an "*",
+	# or "MERGED" if none of them is.
+	#
+	# Example:
+	#
+	#     gen_cmd "LOCAL* | REMOTE"
+	#     |
+	#     `-> FINAL_CMD    == "-c \"echo | vertical split | 1b | wincmd l | 3b | tabdo windo diffthis\" -c \"tabfirst\""
+	#         FINAL_TARGET == "LOCAL"
+
+	local LAYOUT=$1 
+
+
+	# Search for a "*" in one of the files identifiers ("LOCAL", "BASE",
+	# "REMOTE", "MERGED"). If not found, use "MERGE" as the default file
+	# where changes will be saved.
+
+	AUX=$(echo "$LAYOUT" | grep -oe "[A-Z]\+\*")
+
+	if ! test -z "$AUX"
+	then
+	        FINAL_TARGET="${AUX:0:-1}"
+	else
+	        FINAL_TARGET="MERGED"
+	fi
+
+
+	# Obtain the first part of vim "-c" option to obtain the desired layout
+
+	CMD=$(gen_cmd_aux "$LAYOUT")
+
+
+	# Adjust the just obtained script depending on whether more than one
+	# windows are visible or not
+
+	if test $(echo $LAYOUT | wc -w) == "1"
+	then
+		CMD="$CMD | bufdo diffthis"
+        else
+		CMD="$CMD | tabdo windo diffthis"
+	fi
+
+
+	# Add an extra "-c" option to move to the first tab (notice that we
+	# can't simply append the command to the previous "-c" string as
+	# explained here: https://github.com/vim/vim/issues/9076
+
+	FINAL_CMD="-c \"$CMD\" -c \"tabfirst\""
+}
+
+
+run_unit_tests() {
+	# Function to make sure that we don't break anything when modifying this
+	# script.
+	#
+	# This function is automatically executed when you execute this script
+	# from the shell with environment variable "DEBUG_GIT_VIMDIFF" set (to
+	# any value).
+
+	local test_cases=(
+		`#Test case 00` "LOCAL | MERGED | REMOTE"
+		`#Test case 01` "LOCAL - MERGED - REMOTE"
+		`#Test case 02` "(LOCAL - REMOTE) | MERGED"
+		`#Test case 03` "MERGED | (LOCAL - REMOTE)"
+		`#Test case 04` "(LOCAL | REMOTE) - MERGED"
+		`#Test case 05` "MERGED - (LOCAL | REMOTE)"
+		`#Test case 06` "(LOCAL | BASE | REMOTE) - MERGED"
+		`#Test case 07` "(LOCAL - BASE - REMOTE) | MERGED"
+		`#Test case 08` "LOCAL* | REMOTE"
+		`#Test case 09` "MERGED"
+		`#Test case 10` "(LOCAL | BASE | REMOTE) - MERGED; BASE | LOCAL; BASE | REMOTE; (LOCAL - BASE - REMOTE) | MERGED"
+		`#Test case 11` "((LOCAL | REMOTE) - BASE) | MERGED"
+		`#Test case 12` "((LOCAL | REMOTE) - BASE) | ((LOCAL - REMOTE) | MERGED)"
+		`#Test case 13` "BASE | REMOTE ; BASE | LOCAL"
+	)
+
+	local expected_cmd=(
+		`#Test case 00` "-c \"echo | vertical split | 1b | wincmd l | vertical split | 4b | wincmd l | 3b | tabdo windo diffthis\" -c \"tabfirst\""
+		`#Test case 01` "-c \"echo | split | 1b | wincmd j | split | 4b | wincmd j | 3b | tabdo windo diffthis\" -c \"tabfirst\""
+		`#Test case 02` "-c \"echo | vertical split | split | 1b | wincmd j | 3b | wincmd l | 4b | tabdo windo diffthis\" -c \"tabfirst\""
+		`#Test case 03` "-c \"echo | vertical split | 4b | wincmd l | split | 1b | wincmd j | 3b | tabdo windo diffthis\" -c \"tabfirst\""
+		`#Test case 04` "-c \"echo | split | vertical split | 1b | wincmd l | 3b | wincmd j | 4b | tabdo windo diffthis\" -c \"tabfirst\""
+		`#Test case 05` "-c \"echo | split | 4b | wincmd j | vertical split | 1b | wincmd l | 3b | tabdo windo diffthis\" -c \"tabfirst\""
+		`#Test case 06` "-c \"echo | split | vertical split | 1b | wincmd l | vertical split | 2b | wincmd l | 3b | wincmd j | 4b | tabdo windo diffthis\" -c \"tabfirst\""
+		`#Test case 07` "-c \"echo | vertical split | split | 1b | wincmd j | split | 2b | wincmd j | 3b | wincmd l | 4b | tabdo windo diffthis\" -c \"tabfirst\""
+		`#Test case 08` "-c \"echo | vertical split | 1b | wincmd l | 3b | tabdo windo diffthis\" -c \"tabfirst\""
+		`#Test case 09` "-c \"echo | 4b | bufdo diffthis\" -c \"tabfirst\""
+		`#Test case 10` "-c \"echo | -tabnew | split | vertical split | 1b | wincmd l | vertical split | 2b | wincmd l | 3b | wincmd j | 4b | tabnext | -tabnew | vertical split | 2b | wincmd l | 1b | tabnext | -tabnew | vertical split | 2b | wincmd l | 3b | tabnext | vertical split | split | 1b | wincmd j | split | 2b | wincmd j | 3b | wincmd l | 4b | tabdo windo diffthis\" -c \"tabfirst\""
+		`#Test case 11` "-c \"echo | vertical split | split | vertical split | 1b | wincmd l | 3b | wincmd j | 2b | wincmd l | 4b | tabdo windo diffthis\" -c \"tabfirst\""
+		`#Test case 12` "-c \"echo | vertical split | split | vertical split | 1b | wincmd l | 3b | wincmd j | 2b | wincmd l | vertical split | split | 1b | wincmd j | 3b | wincmd l | 4b | tabdo windo diffthis\" -c \"tabfirst\""
+		`#Test case 13` "-c \"echo | -tabnew | vertical split | 2b | wincmd l | 3b | tabnext | vertical split | 2b | wincmd l | 1b | tabdo windo diffthis\" -c \"tabfirst\""
+	)
+
+	local expected_target=(
+		`#Test case 00` "MERGED"
+		`#Test case 01` "MERGED"
+		`#Test case 02` "MERGED"
+		`#Test case 03` "MERGED"
+		`#Test case 04` "MERGED"
+		`#Test case 05` "MERGED"
+		`#Test case 06` "MERGED"
+		`#Test case 07` "MERGED"
+		`#Test case 08` "LOCAL"
+		`#Test case 09` "MERGED"
+		`#Test case 10` "MERGED"
+		`#Test case 11` "MERGED"
+		`#Test case 12` "MERGED"
+		`#Test case 13` "MERGED"
+	)
+
+	local at_least_one_ko="false"
+
+	for i in ${!test_cases[@]}; do
+		gen_cmd "${test_cases[$i]}"
+	
+		if test "$FINAL_CMD" = "${expected_cmd[$i]}" && test "$FINAL_TARGET" = "${expected_target[$i]}"
+		then
+			printf "Test Case #%02d: OK\n" $i
+		else
+			printf "Test Case #%02d: KO !!!!\n" $i
+			echo "  FINAL_CMD              : $FINAL_CMD"
+                        echo "  FINAL_CMD (expected)   : ${expected_cmd[$i]}"
+			echo "  FINAL_TARGET           : $FINAL_TARGET"
+                        echo "  FINAL_TARGET (expected): ${expected_target[$i]}"
+			at_least_one_ko="true"
+		fi
+	done
+	
+	if test "$at_least_one_ko" = "true"
+	then
+		return -1
+	else
+		return 0
+	fi
+}
+
+
+################################################################################
+## API functions (called from "git-mergetool--lib.sh")
+################################################################################
+
 diff_cmd () {
 	"$merge_tool_path" -R -f -d \
 		-c 'wincmd l' -c 'cd $GIT_PREFIX' "$LOCAL" "$REMOTE"
 }
 
+
 merge_cmd () {
+	layout=$(git config mergetool.$merge_tool.layout)
+	print_warning="false"
+
 	case "$1" in
 	*vimdiff)
-		if $base_present
+		if test -z "$layout"
 		then
-			"$merge_tool_path" -f -d -c '4wincmd w | wincmd J' \
-				"$LOCAL" "$BASE" "$REMOTE" "$MERGED"
-		else
-			"$merge_tool_path" -f -d -c 'wincmd l' \
-				"$LOCAL" "$MERGED" "$REMOTE"
+			# Default layout when none is specified
+			layout="(LOCAL | BASE | REMOTE) - MERGED"
 		fi
 		;;
 	*vimdiff1)
-		"$merge_tool_path" -f -d \
-			-c 'echon "Resolve conflicts leftward then save. Use :cq to abort."' \
-			"$LOCAL" "$REMOTE"
-		ret="$?"
-		if test "$ret" -eq 0
-		then
-			cp -- "$LOCAL" "$MERGED"
-		fi
-		return "$ret"
+		layout="LOCAL* | MERGED"
+		print_warning="true"
 		;;
 	*vimdiff2)
-		"$merge_tool_path" -f -d -c 'wincmd l' \
-			"$LOCAL" "$MERGED" "$REMOTE"
+		layout="LOCAL | MERGED | REMOTE"
+		print_warning="true"
 		;;
 	*vimdiff3)
-		if $base_present
-		then
-			"$merge_tool_path" -f -d -c 'hid | hid | hid' \
-				"$LOCAL" "$REMOTE" "$BASE" "$MERGED"
-		else
-			"$merge_tool_path" -f -d -c 'hid | hid' \
-				"$LOCAL" "$REMOTE" "$MERGED"
-		fi
+		layout="MERGED"
+		print_warning="true"
 		;;
 	esac
+
+	if test "$print_warning" = "true"
+	then
+		echo "WARNING:"
+		echo "WARNING: '$1' is going to be removed in a future version. You will be"
+		echo "WARNING: able to obtain the same result by selecting 'vimdiff' as the merge"
+		echo "WARNING: tool and setting configuration variable 'mergetool.vimdiff.layout'"
+		echo "WARNING: to the following value:"
+		echo "WARNING:"
+		echo "WARNING:     layout = \"$layout\""
+		echo "WARNING:"
+		echo "Press ENTER to continue..."
+		read
+	fi
+
+	gen_cmd "$layout"
+
+	debug_print ""
+	debug_print "FINAL CMD : $FINAL_CMD"
+	debug_print "FINAL TAR : $FINAL_TARGET"
+
+	if $base_present
+	then
+		eval "$merge_tool_path" \
+			-f $FINAL_CMD "$LOCAL" "$BASE" "$REMOTE" "$MERGED"
+	else
+		# If there is no BASE (example: a merge conflict in a new file
+		# with the same name created in both braches which didn't exist
+		# before), close all BASE windows using vim's "quit" command
+
+		FINAL_CMD=$(echo $FINAL_CMD | \
+			sed -e 's:2b:quit:g' -e 's:3b:2b:g' -e 's:4b:3b:g')
+
+		eval "$merge_tool_path" \
+			-f $FINAL_CMD "$LOCAL" "$REMOTE" "$MERGED"
+	fi
+
+
+	ret="$?"
+	if test "$ret" -eq 0
+	then
+		if test "$FINAL_TARGET" != "MERGED"
+		then
+			eval cp -- \$"$FINAL_TARGET" "$MERGED"
+		fi
+	fi
+	return "$ret"
 }
 
+
 translate_merge_tool_path() {
 	case "$1" in
 	nvimdiff*)
@@ -57,14 +518,31 @@  translate_merge_tool_path() {
 	esac
 }
 
+
 exit_code_trustable () {
 	true
 }
 
+
 list_tool_variants () {
 	for prefix in '' g n; do
-		for suffix in '' 1 2 3; do
+		for suffix in '' 1 2 3
+		do
 			echo "${prefix}vimdiff${suffix}"
 		done
 	done
 }
+
+
+################################################################################
+## Run unit tests when calling this script from a shell
+################################################################################
+
+if test $(ps -o stat= -p $PPID) = "Ss" && test $(ps -o stat= -p $$) = "S+"
+then
+	# Script is being manually run from command line (see
+	# https://stackoverflow.com/questions/4261876/check-if-bash-script-was-invoked-from-a-shell-or-another-script-application)
+
+	run_unit_tests
+fi
+