Background
I needed to un-merge a branch today (stakeholders backed out--it happens). This meant reverting my preview branch to a commit just before the merge. This is exactly the kind of thing, I think, that pushes so many devs to graphical Git clients (like, GitKraken, et. al.); doing a reset on the command line is kind of pain. You need to run git log, find the earlier hash for a commit you want to reset to, copy it, and then paste it into a git reset command. Really though, if you can run git log the output you need is pretty much staring you in the face. In order to pass the hash to the next command, all you really have to do is "parse" the output (i.e., narrow it down to the exact commit hash you're after) it and store it in a variable. That's actually not too difficult to do.
Use Case
If you've seen my Git-Add-Commit-Push post, you know I like to go big. I want a script for hard resets. Like the 80's guy from Futurama, I'm a shark: I never look back--not unless I've screwed things up so bad that I basically have to throw away all the work I've done since my last stable commit.
What I'd really like is to be able to do is count back in my commits to an earlier commit. I'd like to take that number--i.e., 3 commits back--and use the number to have my shell store the hash for that commit in a variable. I'll look at a way to simply reset to whatever the last commit was, too (basically, whatever the 2nd commit in log history is). Once the variable is stored, it gets passed to git reset --hard and I never look back.
Pro tip: if you'd like to number the output of git log so that it's easier to count back through your commits, try this $ git log -X --oneline --no-abbrev-commit | nl -- replace X with the number of commits you'd like to limit output to.
This script is actually really short. Don't get me wrong--it packs a lot of punch, and it leverages several command line tools. All in all, though, there are only two lines you really need. Before we look at the actual script, though, let's see it in action. Below, after reviewing git log, I decide I need to go back 3 commits. I call my script with an alias, reset, and tell it to go back 3 commits:
The Script
And, now, ladies & gentz: the script:
#! /bin/bash
# ~/Scripts/reset.sh
# grab hash for nth commit in log history
hash=$(echo "$(git log -$1 | grep commit | cut -b 8-)" | tail -n1)
# sanity check
echo "Hard reset to $hash ?" | lolcat
# prompt yes or no and store value in yn var
read yn
# if yn var equals 'y' run git reset; if any other value commence ridicule
[ "$yn" = y ] && git reset --hard $hash || echo "Chicken?" | lolcat
Short and sweet!--but there's actually quite a lot going on here. Let's look at the first line:
# grab hash for nth commit in log history
hash=$(echo "$(git log -$1 | grep commit | cut -b 8-)" | tail -n1)
Let's start with the inner parenthesis. The inner parenthesis consist of three different commands: git log, grep and cut--all piped together into one value. The parenthesis surrounding them "$()" is what groups them into a single value. So, "$(git log -$1 | grep commit | cut -b 8-)" is basically a placeholder for whatever these three commands output when combined. Let's look at each command, starting with git log.
You should be familiar with the output of git log. It's in the above screen grab, but here it is again--just in case:
In this exact screen grab, I knew my last stable commit was three commits ago, so I limited the output to the last three entries, i.e., git log -3. You can run just git log if you don't know how far back the commit you're looking for is. In either case, you do need to count how far back in your history the commit is--that number is passed when I run the script: i.e., $ reset 3. The value of '3' automatically gets stored in the $1 variable of the script. So, coming back to the git command in the parenthetical, git log -$1 would be equal, in this case, to git log -3. Since I know I want to reset to the third commit, I don't want git log to give me any output beyond that commit.
Glancing up at the output of git log again, what in the output can I use to isolate the hashes? Each hash is preceded by the word commit. So in order to show only the commits, I combine git log with grep. I tell grep to look for lines with the word commit, i.e., grep commit. By paring the two commands together, i.e., git log -3 | grep commit, I get a nice clean output with just the commit hashes... and the word "commit" (see screen grab below).
I still need to isolate just the hashes, though--since that's what I pass to the git reset command. I can clean my output up even more by using cut. In order to shave the first seven bytes from each line of the output (namely, "commit "), I tack on a third command: git log -3 | grep commit | cut -b 8-. Here's what the output looks like:
So, the value of "$(git log -$1 | grep commit | cut -b 8-)" is basically three lines of hashes at this point. All that I need to do now is isolate the last one (number 3)--that's the one I'm after.
To isolate the last one, let's now look at the outer parenthesis: $(echo "$(git log -$1 | grep commit | cut -b 8-)" | tail -n1)
Here, I'm basically saying, show the value of the inner parenthesis (a. la. echo), and remove all but the last line with tail. Here's what that looks like on the command line:
So we take all that crazy business and store it in a variable called hash--i.e., hash=$(echo "$(git log -$1 | grep commit | cut -b 8-)" | tail -n1). Then we can take our hash variable and pass it to git reset.
Looking at the script one more time, you should now be able to see everything that's going on:
#! /bin/bash
# ~/Scripts/reset.sh
# grab hash for nth commit in log history
hash=$(echo "$(git log -$1 | grep commit | cut -b 8-)" | tail -n1)
# sanity check
echo "Hard reset to $hash ?" | lolcat
# prompt yes or no and store value in yn var
read yn
# if yn var equals 'y' run git reset; if any other value commence ridicule
[ "$yn" = y ] && git reset --hard $hash || echo "Chicken?" | lolcat
Do you have to pipe the echo commands with lolcat? No. I just like a little color on my terminal. Make sure it's installed before running the script as is.
A 'Last' Option
Most of the time, when I need to do a reset, it's not because of a mistake I made 10 or 20 commits ago. It can happen, of course, but often it's a problem with the commit I just made. In that case, you really don't need to open git log and go looking for a hash. Let's, lastly, build out a way to abandon the counting scheme and instead just pass a value of "last" to our $1 variable: if the $1 variable is set to "last", we just reset to whatever the last commit is.
This sounds a lot harder than it actually is. But, since the last commit is always the second one in the log history, we just need to equate "last" with "2"; we actually don't have to mess with much of what we've already put in the script. We can just add a little logic above what we already have and swap out the $1 variable with a new one called num. Something like this:
#! /bin/bash
# ~/Scripts/reset.sh
if [ "$1 = last ]; then
num="2"
else
num="$1"
fi
# grab hash for nth commit in log history
hash=$(echo "$(git log -$num | grep commit | cut -b 8-)" | tail -n1)
# sanity check
echo "Hard reset to $hash ?" | lolcat
# prompt yes or no and store value in yn var
read yn
# if yn var equals 'y' run git reset; if any other value commence ridicule
[ "$yn" = y ] && git reset --hard $hash || echo "Chicken?" | lolcat
That should do it. With the script properly aliased (say, as 'reset') and set as executable, you could now run it one of two ways:
$ reset X where X = the number of commits back you'd like to reset to, or
$ reset last to simply reset to the last commit.
Without an alias, you would run it like so:
$ ./reset.sh X or $ ./reset.sh last
You don't have to use this script with a hard reset; nor do you even have to use it with the reset command. You could also use this with the checkout command. You could additionally pass the --hard option to the script as a second inline variable when calling the script--you'd need to do some light fidgeting with the script, though. Otherwise, enjoy!