Merge branch 'sg/complete-paths'
authorJunio C Hamano <gitster@pobox.com>
Wed, 30 May 2018 05:04:08 +0000 (14:04 +0900)
committerJunio C Hamano <gitster@pobox.com>
Wed, 30 May 2018 05:04:08 +0000 (14:04 +0900)
Command line completion (in contrib/) learned to complete pathnames
for various commands better.

* sg/complete-paths:
t9902-completion: exercise __git_complete_index_file() directly
completion: don't return with error from __gitcomp_file_direct()
completion: fill COMPREPLY directly when completing paths
completion: improve handling quoted paths in 'git ls-files's output
completion: remove repeated dirnames with 'awk' during path completion
t9902-completion: ignore COMPREPLY element order in some tests
completion: use 'awk' to strip trailing path components
completion: let 'ls-files' and 'diff-index' filter matching paths
completion: improve handling quoted paths on the command line
completion: support completing non-ASCII pathnames
completion: simplify prefix path component handling during path completion
completion: move __git_complete_index_file() next to its helpers
t9902-completion: add tests demonstrating issues with quoted pathnames

contrib/completion/git-completion.bash
contrib/completion/git-completion.zsh
t/t9902-completion.sh
index 961a0ed76f89133dc01fc86a07be37eb5728651a..1491b7239be11cd04c257aabc42944569b806f70 100644 (file)
@@ -94,6 +94,70 @@ __git ()
                ${__git_dir:+--git-dir="$__git_dir"} "$@" 2>/dev/null
 }
 
+# Removes backslash escaping, single quotes and double quotes from a word,
+# stores the result in the variable $dequoted_word.
+# 1: The word to dequote.
+__git_dequote ()
+{
+       local rest="$1" len ch
+
+       dequoted_word=""
+
+       while test -n "$rest"; do
+               len=${#dequoted_word}
+               dequoted_word="$dequoted_word${rest%%[\\\'\"]*}"
+               rest="${rest:$((${#dequoted_word}-$len))}"
+
+               case "${rest:0:1}" in
+               \\)
+                       ch="${rest:1:1}"
+                       case "$ch" in
+                       $'\n')
+                               ;;
+                       *)
+                               dequoted_word="$dequoted_word$ch"
+                               ;;
+                       esac
+                       rest="${rest:2}"
+                       ;;
+               \')
+                       rest="${rest:1}"
+                       len=${#dequoted_word}
+                       dequoted_word="$dequoted_word${rest%%\'*}"
+                       rest="${rest:$((${#dequoted_word}-$len+1))}"
+                       ;;
+               \")
+                       rest="${rest:1}"
+                       while test -n "$rest" ; do
+                               len=${#dequoted_word}
+                               dequoted_word="$dequoted_word${rest%%[\\\"]*}"
+                               rest="${rest:$((${#dequoted_word}-$len))}"
+                               case "${rest:0:1}" in
+                               \\)
+                                       ch="${rest:1:1}"
+                                       case "$ch" in
+                                       \"|\\|\$|\`)
+                                               dequoted_word="$dequoted_word$ch"
+                                               ;;
+                                       $'\n')
+                                               ;;
+                                       *)
+                                               dequoted_word="$dequoted_word\\$ch"
+                                               ;;
+                                       esac
+                                       rest="${rest:2}"
+                                       ;;
+                               \")
+                                       rest="${rest:1}"
+                                       break
+                                       ;;
+                               esac
+                       done
+                       ;;
+               esac
+       done
+}
+
 # The following function is based on code from:
 #
 #   bash_completion - programmable completion functions for bash 3.2+
@@ -346,6 +410,24 @@ __gitcomp_nl ()
        __gitcomp_nl_append "$@"
 }
 
+# Fills the COMPREPLY array with prefiltered paths without any additional
+# processing.
+# Callers must take care of providing only paths that match the current path
+# to be completed and adding any prefix path components, if necessary.
+# 1: List of newline-separated matching paths, complete with all prefix
+#    path componens.
+__gitcomp_file_direct ()
+{
+       local IFS=$'\n'
+
+       COMPREPLY=($1)
+
+       # use a hack to enable file mode in bash < 4
+       compopt -o filenames +o nospace 2>/dev/null ||
+       compgen -f /non-existing-dir/ >/dev/null ||
+       true
+}
+
 # Generates completion reply with compgen from newline-separated possible
 # completion filenames.
 # It accepts 1 to 3 arguments:
@@ -365,7 +447,8 @@ __gitcomp_file ()
 
        # use a hack to enable file mode in bash < 4
        compopt -o filenames +o nospace 2>/dev/null ||
-       compgen -f /non-existing-dir/ > /dev/null
+       compgen -f /non-existing-dir/ >/dev/null ||
+       true
 }
 
 # Execute 'git ls-files', unless the --committable option is specified, in
@@ -375,10 +458,12 @@ __gitcomp_file ()
 __git_ls_files_helper ()
 {
        if [ "$2" == "--committable" ]; then
-               __git -C "$1" diff-index --name-only --relative HEAD
+               __git -C "$1" -c core.quotePath=false diff-index \
+                       --name-only --relative HEAD -- "${3//\\/\\\\}*"
        else
                # NOTE: $2 is not quoted in order to support multiple options
-               __git -C "$1" ls-files --exclude-standard $2
+               __git -C "$1" -c core.quotePath=false ls-files \
+                       --exclude-standard $2 -- "${3//\\/\\\\}*"
        fi
 }
 
@@ -389,12 +474,103 @@ __git_ls_files_helper ()
 #    If provided, only files within the specified directory are listed.
 #    Sub directories are never recursed.  Path must have a trailing
 #    slash.
+# 3: List only paths matching this path component (optional).
 __git_index_files ()
 {
-       local root="${2-.}" file
+       local root="$2" match="$3"
 
-       __git_ls_files_helper "$root" "$1" |
-       cut -f1 -d/ | sort | uniq
+       __git_ls_files_helper "$root" "$1" "$match" |
+       awk -F / -v pfx="${2//\\/\\\\}" '{
+               paths[$1] = 1
+       }
+       END {
+               for (p in paths) {
+                       if (substr(p, 1, 1) != "\"") {
+                               # No special characters, easy!
+                               print pfx p
+                               continue
+                       }
+
+                       # The path is quoted.
+                       p = dequote(p)
+                       if (p == "")
+                               continue
+
+                       # Even when a directory name itself does not contain
+                       # any special characters, it will still be quoted if
+                       # any of its (stripped) trailing path components do.
+                       # Because of this we may have seen the same direcory
+                       # both quoted and unquoted.
+                       if (p in paths)
+                               # We have seen the same directory unquoted,
+                               # skip it.
+                               continue
+                       else
+                               print pfx p
+               }
+       }
+       function dequote(p,    bs_idx, out, esc, esc_idx, dec) {
+               # Skip opening double quote.
+               p = substr(p, 2)
+
+               # Interpret backslash escape sequences.
+               while ((bs_idx = index(p, "\\")) != 0) {
+                       out = out substr(p, 1, bs_idx - 1)
+                       esc = substr(p, bs_idx + 1, 1)
+                       p = substr(p, bs_idx + 2)
+
+                       if ((esc_idx = index("abtvfr\"\\", esc)) != 0) {
+                               # C-style one-character escape sequence.
+                               out = out substr("\a\b\t\v\f\r\"\\",
+                                                esc_idx, 1)
+                       } else if (esc == "n") {
+                               # Uh-oh, a newline character.
+                               # We cant reliably put a pathname
+                               # containing a newline into COMPREPLY,
+                               # and the newline would create a mess.
+                               # Skip this path.
+                               return ""
+                       } else {
+                               # Must be a \nnn octal value, then.
+                               dec = esc             * 64 + \
+                                     substr(p, 1, 1) * 8  + \
+                                     substr(p, 2, 1)
+                               out = out sprintf("%c", dec)
+                               p = substr(p, 3)
+                       }
+               }
+               # Drop closing double quote, if there is one.
+               # (There isnt any if this is a directory, as it was
+               # already stripped with the trailing path components.)
+               if (substr(p, length(p), 1) == "\"")
+                       out = out substr(p, 1, length(p) - 1)
+               else
+                       out = out p
+
+               return out
+       }'
+}
+
+# __git_complete_index_file requires 1 argument:
+# 1: the options to pass to ls-file
+#
+# The exception is --committable, which finds the files appropriate commit.
+__git_complete_index_file ()
+{
+       local dequoted_word pfx="" cur_
+
+       __git_dequote "$cur"
+
+       case "$dequoted_word" in
+       ?*/*)
+               pfx="${dequoted_word%/*}/"
+               cur_="${dequoted_word##*/}"
+               ;;
+       *)
+               cur_="$dequoted_word"
+       esac
+
+       __gitcomp_file_direct "$(__git_index_files "$1" "$pfx" "$cur_")"
 }
 
 # Lists branches from the local repository.
@@ -713,26 +889,6 @@ __git_complete_revlist_file ()
        esac
 }
 
-
-# __git_complete_index_file requires 1 argument:
-# 1: the options to pass to ls-file
-#
-# The exception is --committable, which finds the files appropriate commit.
-__git_complete_index_file ()
-{
-       local pfx="" cur_="$cur"
-
-       case "$cur_" in
-       ?*/*)
-               pfx="${cur_%/*}"
-               cur_="${cur_##*/}"
-               pfx="${pfx}/"
-               ;;
-       esac
-
-       __gitcomp_file "$(__git_index_files "$1" ${pfx:+"$pfx"})" "$pfx" "$cur_"
-}
-
 __git_complete_file ()
 {
        __git_complete_revlist_file
@@ -3232,6 +3388,15 @@ if [[ -n ${ZSH_VERSION-} ]]; then
                compadd -Q -S "${4- }" -p "${2-}" -- ${=1} && _ret=0
        }
 
+       __gitcomp_file_direct ()
+       {
+               emulate -L zsh
+
+               local IFS=$'\n'
+               compset -P '*[=:]'
+               compadd -Q -f -- ${=1} && _ret=0
+       }
+
        __gitcomp_file ()
        {
                emulate -L zsh
index c3521fbfc44fd8db18244bc10d346ee39b1c3c90..53cb0f934f8ba0b468145fe06480f9c99f860996 100644 (file)
@@ -93,6 +93,15 @@ __gitcomp_nl_append ()
        compadd -Q -S "${4- }" -p "${2-}" -- ${=1} && _ret=0
 }
 
+__gitcomp_file_direct ()
+{
+       emulate -L zsh
+
+       local IFS=$'\n'
+       compset -P '*[=:]'
+       compadd -Q -f -- ${=1} && _ret=0
+}
+
 __gitcomp_file ()
 {
        emulate -L zsh
index 1b34caa1e1a5e86cd2edccbb1559009b35022e10..1b6d27525454a1d14da91f53d45e39a659e48e36 100755 (executable)
@@ -84,10 +84,11 @@ test_completion ()
        then
                printf '%s\n' "$2" >expected
        else
-               sed -e 's/Z$//' >expected
+               sed -e 's/Z$//' |sort >expected
        fi &&
        run_completion "$1" &&
-       test_cmp expected out
+       sort out >out_sorted &&
+       test_cmp expected out_sorted
 }
 
 # Test __gitcomp.
@@ -400,6 +401,46 @@ test_expect_success '__gitdir - remote as argument' '
        test_cmp expected "$actual"
 '
 
+
+test_expect_success '__git_dequote - plain unquoted word' '
+       __git_dequote unquoted-word &&
+       verbose test unquoted-word = "$dequoted_word"
+'
+
+# input:    b\a\c\k\'\\\"s\l\a\s\h\es
+# expected: back'\"slashes
+test_expect_success '__git_dequote - backslash escaped' '
+       __git_dequote "b\a\c\k\\'\''\\\\\\\"s\l\a\s\h\es" &&
+       verbose test "back'\''\\\"slashes" = "$dequoted_word"
+'
+
+# input:    sin'gle\' '"quo'ted
+# expected: single\ "quoted
+test_expect_success '__git_dequote - single quoted' '
+       __git_dequote "'"sin'gle\\\\' '\\\"quo'ted"'" &&
+       verbose test '\''single\ "quoted'\'' = "$dequoted_word"
+'
+
+# input:    dou"ble\\" "\"\quot"ed
+# expected: double\ "\quoted
+test_expect_success '__git_dequote - double quoted' '
+       __git_dequote '\''dou"ble\\" "\"\quot"ed'\'' &&
+       verbose test '\''double\ "\quoted'\'' = "$dequoted_word"
+'
+
+# input: 'open single quote
+test_expect_success '__git_dequote - open single quote' '
+       __git_dequote "'\''open single quote" &&
+       verbose test "open single quote" = "$dequoted_word"
+'
+
+# input: "open double quote
+test_expect_success '__git_dequote - open double quote' '
+       __git_dequote "\"open double quote" &&
+       verbose test "open double quote" = "$dequoted_word"
+'
+
+
 test_expect_success '__gitcomp_direct - puts everything into COMPREPLY as-is' '
        sed -e "s/Z$//g" >expected <<-EOF &&
        with-trailing-space Z
@@ -1168,6 +1209,124 @@ test_expect_success 'teardown after ref completion' '
        git remote remove other
 '
 
+
+test_path_completion ()
+{
+       test $# = 2 || error "bug in the test script: not 2 parameters to test_path_completion"
+
+       local cur="$1" expected="$2"
+       echo "$expected" >expected &&
+       (
+               # In the following tests calling this function we only
+               # care about how __git_complete_index_file() deals with
+               # unusual characters in path names.  By requesting only
+               # untracked files we dont have to bother adding any
+               # paths to the index in those tests.
+               __git_complete_index_file --others &&
+               print_comp
+       ) &&
+       test_cmp expected out
+}
+
+test_expect_success 'setup for path completion tests' '
+       mkdir simple-dir \
+             "spaces in dir" \
+             árvíztűrő &&
+       touch simple-dir/simple-file \
+             "spaces in dir/spaces in file" \
+             "árvíztűrő/Сайн яваарай" &&
+       if test_have_prereq !MINGW &&
+          mkdir BS\\dir \
+                '$'separators\034in\035dir'' &&
+          touch BS\\dir/DQ\"file \
+                '$'separators\034in\035dir/sep\036in\037file''
+       then
+               test_set_prereq FUNNYNAMES
+       else
+               rm -rf BS\\dir '$'separators\034in\035dir''
+       fi
+'
+
+test_expect_success '__git_complete_index_file - simple' '
+       test_path_completion simple simple-dir &&  # Bash is supposed to
+                                                  # add the trailing /.
+       test_path_completion simple-dir/simple simple-dir/simple-file
+'
+
+test_expect_success \
+    '__git_complete_index_file - escaped characters on cmdline' '
+       test_path_completion spac "spaces in dir" &&  # Bash will turn this
+                                                     # into "spaces\ in\ dir"
+       test_path_completion "spaces\\ i" \
+                            "spaces in dir" &&
+       test_path_completion "spaces\\ in\\ dir/s" \
+                            "spaces in dir/spaces in file" &&
+       test_path_completion "spaces\\ in\\ dir/spaces\\ i" \
+                            "spaces in dir/spaces in file"
+'
+
+test_expect_success \
+    '__git_complete_index_file - quoted characters on cmdline' '
+       # Testing with an opening but without a corresponding closing
+       # double quote is important.
+       test_path_completion \"spac "spaces in dir" &&
+       test_path_completion "\"spaces i" \
+                            "spaces in dir" &&
+       test_path_completion "\"spaces in dir/s" \
+                            "spaces in dir/spaces in file" &&
+       test_path_completion "\"spaces in dir/spaces i" \
+                            "spaces in dir/spaces in file"
+'
+
+test_expect_success '__git_complete_index_file - UTF-8 in ls-files output' '
+       test_path_completion á árvíztűrő &&
+       test_path_completion árvíztűrő/С "árvíztűrő/Сайн яваарай"
+'
+
+test_expect_success FUNNYNAMES \
+    '__git_complete_index_file - C-style escapes in ls-files output' '
+       test_path_completion BS \
+                            BS\\dir &&
+       test_path_completion BS\\\\d \
+                            BS\\dir &&
+       test_path_completion BS\\\\dir/DQ \
+                            BS\\dir/DQ\"file &&
+       test_path_completion BS\\\\dir/DQ\\\"f \
+                            BS\\dir/DQ\"file
+'
+
+test_expect_success FUNNYNAMES \
+    '__git_complete_index_file - \nnn-escaped characters in ls-files output' '
+       test_path_completion sep '$'separators\034in\035dir'' &&
+       test_path_completion '$'separators\034i'' \
+                            '$'separators\034in\035dir'' &&
+       test_path_completion '$'separators\034in\035dir/sep'' \
+                            '$'separators\034in\035dir/sep\036in\037file'' &&
+       test_path_completion '$'separators\034in\035dir/sep\036i'' \
+                            '$'separators\034in\035dir/sep\036in\037file''
+'
+
+test_expect_success FUNNYNAMES \
+    '__git_complete_index_file - removing repeated quoted path components' '
+       test_when_finished rm -r repeated-quoted &&
+       mkdir repeated-quoted &&      # A directory whose name in itself
+                                     # would not be quoted ...
+       >repeated-quoted/0-file &&
+       >repeated-quoted/1\"file &&   # ... but here the file makes the
+                                     # dirname quoted ...
+       >repeated-quoted/2-file &&
+       >repeated-quoted/3\"file &&   # ... and here, too.
+
+       # Still, we shold only list the directory name only once.
+       test_path_completion repeated repeated-quoted
+'
+
+test_expect_success 'teardown after path completion tests' '
+       rm -rf simple-dir "spaces in dir" árvíztűrő \
+              BS\\dir '$'separators\034in\035dir''
+'
+
+
 test_expect_success '__git_get_config_variables' '
        cat >expect <<-EOF &&
        name-1
@@ -1365,6 +1524,7 @@ test_expect_success 'complete files' '
 
        echo "expected" > .gitignore &&
        echo "out" >> .gitignore &&
+       echo "out_sorted" >> .gitignore &&
 
        git add .gitignore &&
        test_completion "git commit " ".gitignore" &&