From 7b86062720f594cb7c3568b44a0644f4e02e3892 Mon Sep 17 00:00:00 2001 From: Fabian Homborg Date: Fri, 15 Jun 2018 14:05:15 +0200 Subject: [PATCH] [completions/git] Allow relative paths for files This is _ugly_ because it is implemented for both status --porcelain formats, but it is both fast and accurate. --- share/completions/git.fish | 340 ++++++++++++++++++++++++++----------- 1 file changed, 242 insertions(+), 98 deletions(-) diff --git a/share/completions/git.fish b/share/completions/git.fish index 4b6c3b436..ecf49c5e1 100644 --- a/share/completions/git.fish +++ b/share/completions/git.fish @@ -89,120 +89,264 @@ function __fish_git_files # Save the repo root to remove it from the path later. set -l root (command git rev-parse --show-toplevel 2>/dev/null) - # Do not continue if not inside a Git repository or return # Cache the translated descriptions so we don't have to get it # once per file. - # This is slightly slower for < 8 files, but that is fast enough anyway. - set -l unmerged_desc (_ "Unmerged File") - set -l added_desc (_ "Added file") - set -l modified_desc (_ "Modified file") - set -l staged_modified_desc (_ "Staged modified file") - set -l deleted_desc (_ "Deleted file") - set -l staged_deleted_desc (_ "Staged deleted file") - set -l untracked_desc (_ "Untracked file") - set -l ignored_desc (_ "Ignored file") + contains -- unmerged $argv; and set -l unmerged + and set -l unmerged_desc (_ "Unmerged File") + contains -- added $argv; or contains -- all-staged $argv; and set -l added + and set -l added_desc (_ "Added file") + contains -- modified $argv; and set -l modified + and set -l modified_desc (_ "Modified file") + contains -- untracked $argv; and set -l untracked + and set -l untracked_desc (_ "Untracked file") + contains -- modified-staged $argv; or contains -- all-staged $argv; and set -l modified_staged + and set -l staged_modified_desc (_ "Staged modified file") + contains -- deleted $argv; and set -l deleted + and set -l deleted_desc (_ "Deleted file") + contains -- deleted-staged $argv; or contains -- all-staged $argv; and set -l deleted_staged + and set -l staged_deleted_desc (_ "Staged deleted file") + contains -- ignored $argv; and set -l ignored + and set -l ignored_desc (_ "Ignored file") + contains -- renamed $argv; and set -l renamed + and set -l renamed_desc (_ "Renamed file") + contains -- copied $argv; and set -l copied + and set -l copied_desc (_ "Copied file") + # A literal "?" for use in `case`. + set -l q '\\?' + if status test-feature qmark-noglob + set q '?' + end + set -l use_next # git status --porcelain gives us all the info we need, in a format we don't. # The v2 format has better documentation and doesn't use " " to denote anything, # but it's only been added in git 2.11.0, which was released November 2016. - # Instead, we use the v1 format, without explicitly specifying it (since that errors out as well). - # + # Also, we ignore submodules because they aren't useful as arguments (generally), # and they slow things down quite significantly. # E.g. `git reset $submodule` won't do anything (not even print an error). # --ignore-submodules=all was added in git 1.7.2, released July 2010. - set -l use_next - command git status --porcelain -z --ignore-submodules=all \ - | while read -lz -d '' line - # The entire line is the "from" from a rename. - if set -q use_next[1] - if contains -- $use_next $argv - string replace -f -- "$PWD/" "" "$root/$line" - or string replace -- "$root/" ":/" "$root/$line" + # + set -l status_opt --ignore-submodules=all + + # If we aren't looking for untracked/ignored files, let git status skip them. + set -q untracked; and set -a status_opt -unormal + or set -a status_opt -uno + set -q ignored; and set -a status_opt --ignored=matching + or set -a status_opt --ignored=no + + # We pick the v2 format if we can, because it shows relative filenames (if used without "-z"). + # We fall back on the v1 format by reading git's _version_, because trying v2 first is too slow. + set -l ver (command git --version | string replace -rf 'git version (\d+)\.(\d+)\.?.*' '$1\n$2') + # Version >= 2.11.* has the v2 format. + if test "$ver[1]" -gt 2 2>/dev/null; or test "$ver[1]" -eq 2 -a "$ver[2]" -ge 11 2>/dev/null + # We need to set status.relativePaths to true because we want relative paths, + # and core.quotePath to false so characters > 0x80 (i.e. non-ASCII) aren't escaped. + command git -c status.relativePaths -c core.quotePath= status --porcelain=2 $status_opt \ + | while read -la -d ' ' line + set -l file + set -l desc + # The basic status format is "XY", where X is "our" state (meaning the staging area), + # and "Y" is "their" state. + # A "." means it's unmodified. + switch "$line[1..2]" + case 'u *' + # Unmerged + # "Unmerged entries have the following format; the first character is a "u" to distinguish from ordinary changed entries." + # "u

" + # This is first to distinguish it from normal modifications et al. + set -ql unmerged + and set file "$line[11..-1]" + and set desc $unmerged_desc + case '2 .R*' '2 R.*' + # Renamed/Copied + # From the docs: "Renamed or copied entries have the following format:" + # "2 " + # Since is \t, we can't really parse it unambiguously. + # The "-z" format would be great here! + set -ql renamed + and set file (string replace -r '\t[^\t]*' '' -- "$line[10..-1]") + and set desc $renamed_desc + case '2 .C*' '2 C.*' + set -ql copied + and set file (string replace -r '\t[^\t].*' '' -- "$line[10..-1]") + and set desc $copied_desc + case '1 A.*' + # Additions are only shown here if they are staged. + # Otherwise it's an untracked file. + set -ql added + and set file "$line[9..-1]" + and set desc $added_desc + case '1 .M*' + # Modified + # From the docs: "Ordinary changed entries have the following format:" + # "1 " + # Since can contain spaces, print from element 9 onwards + set -ql modified + and set file "$line[9..-1]" + and set desc $modified_desc + case '1 M.*' + # If the character is first ("M."), then that means it's "our" change, + # which means it is staged. + # This is useless for many commands - e.g. `checkout` won't do anything with this. + # So it needs to be requested explicitly. + set -ql modified_staged + and set file "$line[9..-1]" + and set desc $staged_modified_desc + case '1 .D*' + set -ql deleted + and set file "$line[9..-1]" + and set desc $deleted_desc + case '1 D.*' + # TODO: The docs are unclear on this. + # There is both X unmodified and Y either M or D ("not updated") + # and Y is D and X is unmodified or [MARC] ("deleted in work tree"). + # For our purposes, we assume this is a staged deletion. + set -ql deleted-staged; or set -ql all-staged + and set file "$line[9..-1]" + and set desc $staged_deleted_desc + case "$q"' *' + # Untracked + # "? " - print from element 2 on. + set -ql untracked + and set file "$line[2..-1]" + and set desc $untracked_desc + case '! *' + # Ignored + # "! " - print from element 2 on. + set -ql ignored + and set file "$line[2..-1]" + and set desc $ignored_desc + end + # Only try printing if the file was selected. + if set -q file[1] + # Without "-z", git sometimes _quotes_ filenames. + # It adds quotes around it _and_ escapes the character. + # e.g. `"a\\b"`. + # We just remove the quotes and hope it works out. + # If this contains newlines or tabs, + # there is nothing we can do, but that's a general issue with scripted completions. + set file (string trim -c \" -- $file) + # First the relative filename. + printf '%s\t%s\n' "$file" $desc + # Now from repo root. + set -l fromroot (builtin realpath -- $file 2>/dev/null) + and set fromroot (string replace -- "$root/" ":/" "$fromroot") + and printf '%s\t%s\n' "$fromroot" $desc end - set -e use_next[1] - continue end + else + # v1 format logic + # We need to compute relative paths on our own, which is slow. + # Pre-remove the root at least, so we have fewer components to deal with. + set -l _pwd_list (string replace "$root/" "" -- $PWD | string split /) + # Cache the previous relative path because these are sorted, so we can reuse it + # often for files in the same directory. + set -l previous + set -l previousfile + # Note that we can't use space as a delimiter between status and filename, because + # the status can contain spaces - " M" is different from "M ". + command git -c core.quotePath= status --porcelain -z $status_opt \ + | while read -lz line + set -l desc + # The entire line is the "from" from a rename. + if set -q use_next[1] + if contains -- $use_next $argv + set -l var "$use_next"_desc + set desc $$var + set -e use_next[1] + else + set -e use_next[1] + continue + end + end - # The format is two characters for status, then a space and then - # up to a NUL for the filename. - # - # Use IFS to handle newlines in filenames. - set -l IFS - set -l stat (string sub -l 2 -- $line) - set -l file (string sub -s 4 -- $line) - # Print files from the current $PWD as-is, prepend all others with ":/" (relative to toplevel in git-speak) - # This is a bit simplistic but finding the lowest common directory - # and then replacing everything else in $PWD with ".." is a bit annoying - set file (string replace -f -- "$PWD/" "" "$root/$file"; or string replace -- "$root/" ":/" "$root/$file") - set -e IFS + # The format is two characters for status, then a space and then + # up to a NUL for the filename. + # + set -l stat (string sub -l 2 -- $line) + # The basic status format is "XY", where X is "our" state (meaning the staging area), + # and "Y" is "their" state (meaning the work tree). + # A " " means it's unmodified. + # + # Be careful about the ordering here! + switch "$stat" + case DD AU UD UA DU AA UU + # Unmerged + set -ql unmerged + and set desc $unmerged_desc + case 'R ' RM RD + # Renamed/Copied + # These have the "from" name as the next batch. + # TODO: Do we care about the new name? + set use_next renamed + continue + case 'C ' CM CD + set use_next copied + continue + case 'A ' AM AD + # Additions are only shown here if they are staged. + # Otherwise it's an untracked file. + set -ql added + and set desc $added_desc + case '*M' + # Modified + set -ql modified + and set desc $modified_desc + case 'M*' + # If the character is first ("M "), then that means it's "our" change, + # which means it is staged. + # This is useless for many commands - e.g. `checkout` won't do anything with this. + # So it needs to be requested explicitly. + set -ql modified_staged + and set desc $staged_modified_desc + case '*D' + set -ql deleted + and set desc $deleted_desc + case 'D*' + # TODO: The docs are unclear on this. + # There is both X unmodified and Y either M or D ("not updated") + # and Y is D and X is unmodified or [MARC] ("deleted in work tree"). + # For our purposes, we assume this is a staged deletion. + set -ql deleted_staged + and set desc $staged_deleted_desc + case "$q$q" + # Untracked + set -ql untracked + and set desc $untracked_desc + case '!!' + # Ignored + set -ql ignored + and set desc $ignored_desc + end + if set -q desc[1] + # Again: "XY filename", so the filename starts on character 4. + set -l relfile (string sub -s 4 -- $line) - # The basic status format is "XY", where X is "our" state (meaning the staging area), - # and "Y" is "their" state (meaning the work tree). - # A " " means it's unmodified. - # - # Be careful about the ordering here! - # - # HACK: To allow this to work both with and without '?' globs - set -l dq '\\?\\?' - if status test-feature qmark-noglob - # ? is not a glob - set dq '??' - end - switch "$stat" - case DD AU UD UA DU AA UU - # Unmerged - # TODO: It might be useful to split this up. - contains -- unmerged $argv - and printf '%s\t%s\n' "$file" $unmerged_desc - case 'R ' RM RD - # Renamed/Copied - # These have the "from" name as the next batch. - # TODO: Do we care about the new name? - set use_next renamed - continue - case 'C ' CM CD - set use_next copied - continue - case 'A ' AM AD - # Additions are only shown here if they are staged. - # Otherwise it's an untracked file. - contains -- added $argv - or contains -- all-staged $argv - and printf '%s\t%s\n' "$file" $added_desc - case '*M' - # Modified - contains -- modified $argv - and printf '%s\t%s\n' "$file" $modified_desc - case 'M*' - # If the character is first ("M "), then that means it's "our" change, - # which means it is staged. - # This is useless for many commands - e.g. `checkout` won't do anything with this. - # So it needs to be requested explicitly. - contains -- modified-staged $argv - or contains -- all-staged $argv - and printf '%s\t%s\n' "$file" $staged_modified_desc - case '*D' - contains -- deleted $argv - and printf '%s\t%s\n' "$file" $deleted_desc - case 'D*' - # TODO: The docs are unclear on this. - # There is both X unmodified and Y either M or D ("not updated") - # and Y is D and X is unmodified or [MARC] ("deleted in work tree"). - # For our purposes, we assume this is a staged deletion. - contains -- deleted-staged $argv - or contains -- all-staged $argv - and printf '%s\t%s\n' "$file" $staged_deleted_desc - case "$dq" # a literal '??' - # Untracked - contains -- untracked $argv - and printf '%s\t%s\n' "$file" $untracked_desc - case '!!' - # Ignored - contains -- ignored $argv - and printf '%s\t%s\n' "$file" $ignored_desc + # The filename with ":/" prepended. + set -l file (string replace -- "$root/" ":/" "$root/$relfile") + + # Computing relative path by hand. + set -l abs (string split / -- $relfile) + # If it's in the same directory, we just need to change the filename. + if test "$abs[1..-2]" = "$previousfile[1..-2]" + set previous[-1] $abs[-1] + else + set -l pwd_list $_pwd_list + set previousfile $abs + # Remove common prefix + while test "$pwd_list[1]" = "$abs[1]" + set -e pwd_list[1] + set -e abs[1] + end + # Go a dir up for every entry left in pwd_list, then into $abs + set previous (string replace -r '.*' '..' -- $pwd_list) $abs + end + set -a file (string join / -- $previous) + printf '%s\n' $file\t$desc + end end end end