git checkout partial targets

2020-08-22

 | 

~7 min read

 | 

1349 words

Recently, I had to do something I’d done dozens, if not hundreds of times before: pull down a colleague’s code so that I could test it. There are always a few steps involved in this step, so I decided to write a small function to help myself. I called it gcot for git checkout track:

$HOME/.zshrc
#...
function gcot(){
    TARGET=$1
    git fetch && git checkout -t $(git branch -a | grep $TARGET)
}

This works like a charm…

$ git branch --all
add-settings-to-sidenav-EXPO-1304
  develop
* 1230-local-only
  1284-feature-abc
  remotes/origin/HEAD -> origin/develop
  remotes/origin/1284-feature-abc
  remotes/origin/1420-feature-xyz
$ gcot 1420
remote: Enumerating objects: 1, done.
remote: Counting objects: 100% (1/1), done.
remote: Total 1 (delta 0), reused 1 (delta 0), pack-reused 0
Unpacking objects: 100% (1/1), done.
From github.com:stephencweiss/test-project
 * [new tag]         v0.1.1014-release-1.55 -> v0.1.1014-release-1.55
Branch '1420-feature-xyz' set up to track remote branch '1420-feature-xyz' from 'origin'.
Switched to a new branch '1420-feature-xyz'

… as long as I wasn’t tracking the branch locally. If I had a local copy the track (-t) would fail.

failing-gcot
$ gcot 1284-feature-abc # this was already tracked locally
fatal: missing branch name; try -b

As a reminder, here’s the description of the --track option for git checkout:

checkout-track
-t, --track
           When creating a new branch, set up "upstream" configuration. See "--track" in git-branch(1) for details.

           If no -b option is given, the name of the new branch will be derived from the remote-tracking branch, by looking at the local part of the refspec configured for the corresponding remote, and then stripping the initial part up to the "*". This would tell us to use hack as the local branch when branching off of origin/hack
           (or remotes/origin/hack, or even refs/remotes/origin/hack). If the given name has no slash, or the above guessing results in an empty name, the guessing is aborted. You can explicitly give a name with -b in such a case.

This raised a related question - what if I wanted to switch between branches I had locally without typing out the full name? After all, my branch naming convention can be rather verbose which can be a pain to type the entire thing, but its saving grace is that I include a ticket number. That number, being unique, could be used as a target.

I already had most of the pieces, I just needed a slight refactor:

$HOME/.zshrc
# ...
function gcop(){
    TARGET=$1
    git fetch && git checkout -t $(git branch -a | grep -v remotes | grep $TARGET)
}

The only change is adding grep -v remotes. This removes any branches that aren’t stored locally and was inspired by Chris Holtz who had arrived at a very similar problem and wrote about it.

I wasn’t satisfied with having two separate functions, however, so I kept tweaking.

My first solution was to figure out if there was a local version. If there was, I’d use that, else I’d default to the first remote branch I could find.

This led to using a branching strategy

$HOME/.zshrc
#...
function gcop(){
    TARGET=$1
    git fetch
    LOCAL_BRANCH=$(git branch -a |  grep $TARGET | grep -v remotes | head -n 1)
    REMOTE_BRANCH=$(git branch -a |  grep $TARGET | head -n 1)

    if [ -z "$LOCAL_BRANCH"] # checks if LOCAL_BRANCH is defined
    then
        git checkout -t $REMOTE_BRANCH
    else
        git checkout $LOCAL_BRANCH
    fi
}

The issue here is that the REMOTE_BRANCH variable had leading spaces. It’s possible to trim leading spaces using sed… Which is exactly what I was going to do until I noticed a previously unknown option: guess:

checkout-guess
--guess, --no-guess
           If <branch> is not found but there does exist a tracking branch in exactly one remote (call it <remote>) with a matching name, treat as equivalent to

               $ git checkout -b <branch> --track <remote>/<branch>

           If the branch exists in multiple remotes and one of them is named by the checkout.defaultRemote configuration variable, we'll use that one for the
           purposes of disambiguation, even if the <branch> isn't unique across all remotes. Set it to e.g.  checkout.defaultRemote=origin to always checkout
           remote branches from there if <branch> is ambiguous but exists on the origin remote. See also checkout.defaultRemote in git-config(1).

           Use --no-guess to disable this.

This seemed to be describing exactly what I was looking for: try local, fall back to remote. It’s actually quite similar to the default behavior for git checkout <branch>. Per this Stack Overflow answer by Andrew C:

When you have only a single remote (let’s call it origin) then when you type

git checkout foo

when foo doesn’t exist but origin/foo does exist git will behave as though you typed the following

git checkout -b foo origin/foo

If you have multiple remotes, and foo does not exist locally but exists in 2 or more remotes then this behavior is suppressed.

You will need to explicitly create foo and instruct git what remote/branch you want it to track.

/foo

Per the manual, the only difference is that I’m explicitly tracking the /foo by using guess.

Unfortunately, this didn’t work as I expected (I wasn’t quite able to root out why), so I returned to the branching strategy. Though I didn’t use the sed method:

$HOME/.zshrc
#...
function gcop(){
    TARGET=$1
    git fetch
    LOCAL_BRANCH=$(git branch -a | grep -v remotes | grep $TARGET | head -n 1)
    REMOTE_BRANCH=$(git branch -a | grep $TARGET | head -n 1)

    if [ ! -z "$LOCAL_BRANCH" ]
    then
        echo "\$LOCAL_BRANCH is NOT empty. "
        echo $LOCAL_BRANCH | xargs git checkout
    elif [ ! -z "$REMOTE_BRANCH" ]
    then
        echo "\$LOCAL_BRANCH is empty; \$REMOTE_BRANCH is NOT empty. "
        echo $REMOTE_BRANCH | xargs git checkout -t
    else
        echo "No branch matches the pattern $1, available options are: try git branch --all to see options"
    fi
}

Why does this work when the other one didn’t? xargs! xargs automatically trims white space!1 By using echo to pipe my variable into the command declared by xargs, I was able to avoid the regex mess with sed.

I also considered renaming my function from gcop to simply gco, replacing my original alias for git checkout, since this now covered that case as well. I decided against it however, because that would have meant I didn’t have access to things like -b to create a new branch, which certainly isn’t desirable.

Gotchas

There’s one big gotcha with this approach: what happens when there are multiple branches that match the target? This approach will simply pull the first one it finds. So, given the example where I’m using the ticket number, if that’s not unique, then it might lead to unexpected results.

A friend of mine suggested using fzf an interactive fuzzy finder to solve the problem:

branch
# fuzzy git branch checkout
function branch () {
    git fetch
    local branches branch
    branches=$(git branch -a) &&
        branch=$(echo "$branches" | fzf +s +m -e) &&
        git checkout $(echo "$branch" | sed "s:.* remotes/origin/::" | sed "s:.* ::")
}

In this case, instead of starting with a target in mind, you open the interactive editor with branch and then can type to narrow it down. If more than one branch is found in the end, you can navigate to the desired branch and select with the keyboard.

Conclusion

The final solution isn’t quite as simple as I may have wished, however, it’s working in all the situations I’ve thrown at it so far and that’s about all I need at the moment. Plus I learned a lot about writing bash functions and shell scripting along the way! Including what that -z character is all about

Footnotes

  • 1 There are a few caveats that are worth calling out with respect to using xargs for trimming purposes, e.g., it will only work if the string is one line, it will condense spaces between words (e.g., <space/ becomes word), and several others, which the comments on this Stack Overflow answer do a nice job of listing. However, for my purposes, it worked perfectly.

Hi there and thanks for reading! My name's Stephen. I live in Chicago with my wife, Kate, and dog, Finn. Want more? See about and get in touch!