6
\$\begingroup\$

Whenever I do git status, I want to reference the files that were shown easily... whether it be so I can open it in vim, git add it, delete it, git checkout (to revert), etc. So after a bit of looking around, I couldn't find any existing script for this so I made my own. It works, but the code is not pretty, and being a beginner bash scripter (if that's even a noun), I feel like I can do so much better with this.

Here is the script:

#usage gits: sets env variables $m<n> $d<n> $u<n> $a (modified, deleted, untracked, added)
function gits {
    git status
    gitsall=$(git status -s)
    m=$(echo "$gitsall" | grep "^ M")
    d=$(echo "$gitsall" | grep "^ D")
    u=$(echo "$gitsall" | grep "^??")
    a=$(echo "$gitsall" | grep "^A ")
    s=$(echo "$gitsall" | grep "^M ")
    #i=0
    #while [[ -n "$(echo {m,d,u}$(($i)))" ]]; do
#   i=$(($i+1))
#   unset {m,d,u}$(($i))
#    done
    count=1
    while read -r tmpfilename; do
    tmpfilename=${tmpfilename:2}
    set m$(($count))="$(pwd)/$tmpfilename"
    count=$(($count+1))
    done <<< "$m"
    count=1
    while read -r tmpfilename; do
    tmpfilename=${tmpfilename:2}
    set d$(($count))="$(pwd)/$tmpfilename"
    count=$(($count+1))
    done <<< "$d"
    count=1
    while read -r tmpfilename; do
    tmpfilename=${tmpfilename:3}
    set u$(($count))="$(pwd)/$tmpfilename"
    count=$(($count+1))
    done <<< "$u"
    count=1
    while read -r tmpfilename; do
    tmpfilename=${tmpfilename:3}
    set a$(($count))="$(pwd)/$tmpfilename"
    count=$(($count+1))
    done <<< "$a"
    count=1
    while read -r tmpfilename; do
    tmpfilename=${tmpfilename:3}
    set s$(($count))="$(pwd)/$tmpfilename"
    count=$(($count+1))
    done <<< "$s"

    unset m d u a s tmpfilename gitsall
}

Basically, I call git status -s, save the output as a variable, then grep it to get lists of filenames of modified, deleted, untracked, and added.

I then parse each line individually and set $ to the respective file in absolute paths.

Now, much of the parsing is the same. Things that vary are env var prefix, substring location (2 or 3 so far), and file list var name. I feel like this can somehow be cleaned up nicely.

Note: I do know this is not going to cover everything. Namely, I only so far implemented it for " M" " D" "??" "A ", but I know others exist like "M ", "MM", etc. which I eventually plan to address. This script is still a WIP for me but I wanted to clean it up before I add all the change types.

I did look around google thinking someone has made something like this because I thought it'll be something people would find convenient. For those who have different workflows that work around my situation, it'll be nice if I can hear them too.

Example usage:

gits

Output of git status and set the appropriate vars

vim $m1

Opens first modified, untracked file in the list from git status

\$\endgroup\$

2 Answers 2

6
\$\begingroup\$

Nice try

That's a fantastic idea. It's very annoying to run git status and then very often type some next command and copy paste selected files from the output. I long wanted to have something like this but never made time to actually do it. You gave me the final push, so thanks.

Setting variables dynamically

I don't think your script actually works :-) At least on my computer, in Bash 3 or 4, it's not possible to create variables dynamically with set, for example:

m1=
c=1
set m$c=x
echo $m1

This outputs nothing, because set m$c=x does not actually perform m1=x.

After some research, I found that this works with declare:

m1=
c=1
declare m$c=x
echo $m1

This outputs x alright. Unfortunately, according to help declare:

[...] When
used in a function, makes NAMEs local, as with the `local' command.

That is, the variables created dynamically this way inside the function will not be visible outside, so this is not usable for this purpose.

One obvious option is eval, but not a good one, because eval is evil. A well-crafted filename in the working tree could wreak havoc. Even if this is unlikely, I would rather not resort to such option.

Finally, there's a trick with printf -v that will work well:

m1=
c=1
printf -v m$c x
echo $m1

This outputs x, and even when used in a function, m1 will be visible outside. The only downside is that printf is not POSIX compliant, but that's probably not a big problem in practice.

Unnecessary repeated processing of git status -s

This is really quite wasteful:

m=$(echo "$gitsall" | grep "^ M")
d=$(echo "$gitsall" | grep "^ D")
u=$(echo "$gitsall" | grep "^??")
a=$(echo "$gitsall" | grep "^A ")
s=$(echo "$gitsall" | grep "^M ")

It would be better to run git status -s just once, loop over the output, use a case statement to decide the type of the change, and set the m, d, u, a, s variables accordingly.

When you go this way, you can also directly create the variables with a count, so that you don't need all those repetitive while loops for each status type.

Arithmetic context

When within $((...)), you don't need to write $ to access the values of variables, for example instead of:

count=$(($count+1))

You can write:

count=$((count+1))

An even shorter equivalent:

((count++))

Current working directory

Instead of $(pwd) it's better to use the $PWD variable.

Local variables

Instead of using the unset command at the end of the function to clear the variables used, it's much better to use local variables, for example:

local m d u a s

Not only this will make sure you don't clear the variables, it also makes your use correct. The current use is not correct, because if some of these variables had value before calling the function, the values get reset. When using local, the original values of those variables remain intact in the calling environment.

Alternative implementation

Putting the above together, the function could be written simpler as:

gits() {
    git status
    mc=1 dc=1 uc=1 ac=1 sc=1
    local line status path name
    while read -r line; do
        [ "$line" ] || continue
        status=${line:0:2}
        path=${line:3}
        case "$status" in
            " M") name=m$((mc++)) ;;
            " D") name=d$((dc++)) ;;
            "??") name=u$((uc++)) ;;
            "A ") name=a$((ac++)) ;;
            "M ") name=s$((sc++)) ;;
            *) echo unsupported status on line: $line
        esac
        printf -v $name "$path"
    done <<< "$(git status -s)"
}
\$\endgroup\$
4
  • 1
    \$\begingroup\$ wow great! It looks so much cleaner now. Also, regarding the set part, it worked on the computer I was writing this on but didn't work for another. Changing it to export also worked, although I didn't want that because it should really be local. One thing though: this does not handle the case when git status -s is empty (ie: no change), which can be common. I know it doesn't really matter because you can ignore the message, but for completion, I added: if [ "${line}" = "" ] then continue fi at the beginning of the loop \$\endgroup\$
    – C. Sano
    Commented Aug 21, 2017 at 15:09
  • 1
    \$\begingroup\$ @C.Sano you're right, the empty output needed to be handled. Thanks, and I improved your edit a bit. I'm not sure what you mean by "export" there... \$\endgroup\$
    – janos
    Commented Aug 21, 2017 at 15:32
  • 1
    \$\begingroup\$ you can replace set with export and it should work in general (referring to when you mentioned set didn't work on yours). \$\endgroup\$
    – C. Sano
    Commented Aug 21, 2017 at 15:59
  • 1
    \$\begingroup\$ @C.Sano cool, didn't know that about export \$\endgroup\$
    – janos
    Commented Aug 21, 2017 at 17:27
3
\$\begingroup\$

I'll limit myself to points not addressed by janos' critique. Firstly, when parsing lines using while-read loops, you should ask yourself if you want bash to strip whitespace in the line. In this case, given that we may have lines like M file, the answer is no, and IFS should be set to an empty string. I have edited janos' answer to reflect this critique, as it was a showstopping bug as written.

The other concern I have is the use of dynamically-named variables. While your hack can be made to work, it is pretty magic and will make it harder for the next person reading to understand.

Moreover, I'm not sure what's to be gained by trying to adhere to POSIX -- you're already firmly in Bash territory due to your use of substring indexing, and would be going further there by using printf -v and local as janos suggests (thanks Shellcheck for flagging these!).

Accepting that, we gain access to Bash's data structures, specifically arrays:

gits() {
    git status
    m=() d=() u=() a=() s=()
    local line status path name
    while IFS= read -r line; do
        [ "$line" ] || continue
        status=${line:0:2}
        path=${line:3}
        case "$status" in
            " M") m+=("$path") ;;
            " D") d+=("$path") ;;
            "??") u+=("$path") ;;
            "A ") a+=("$path") ;;
            "M ") s+=("$path") ;;
            *) echo unsupported status on line: "$line"
        esac
    done < <(git status -s)
}

Now, we can access the first unstaged modified file as "${m[0]}", no dynamic variables needed (though, given the likely unpredictability of these indeces, I'm guessing you'll probably be using "${m[@]}" instead -- it expands to every entry of m, so can be used eg to populate the list of files to open in vim)

However, I appreciate the reminder of the power of dynamic variable names. Kudos on figuring that one out!

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.