Skip to content

Instantly share code, notes, and snippets.

@esmooov
Created May 25, 2012 16:50
Show Gist options
  • Star 32 You must be signed in to star a gist
  • Fork 5 You must be signed in to fork a gist
  • Save esmooov/2789156 to your computer and use it in GitHub Desktop.
Save esmooov/2789156 to your computer and use it in GitHub Desktop.
Carats and Tildes, Resets and Reverts

Until last night I lived in fear of tildes, carats, resets and reverts in Git. I cargo culted, I destroyed, I laid waste the tidy indicies, branches and trees Git so diligently tried to maintain. Then Zach Holman gave a talk at Paperless Post. It was about Git secrets. He didn't directly cover these topics but he gave an example that made me realize it was time to learn.

A better undo

Generally, when I push out bad code, I panic, hit git reset --hard HEAD^, push and clean up the pieces later. I don't even really know what most of that means. Notational Velocity seems to be fond of it ... in that I just keep copying it from Notational Velocity and pasting it. Turns out, this is dumb. I've irreversibly lost the faulty changes I made. I'll probably even make the same mistakes again. It's like torching your house to get rid of some mice.

Enter Holman. He suggests a better default undo. git reset --soft HEAD^. Says it stages the last commit's changes so you can push two commits ago. Then work from your changes that broke the build. Okay. I need to figure out what this all means.

The flavors of reset

Turns out, there are bunches of resets. Vanilla, --mixed, --hard, --soft, --merge, -keep, and --patch. Oh God, what do they all do. The reset reference is ... a bit obscure. So here's the lowdown

  • vanilla: If you just git reset it will unstage all staged files. git reset HEAD file unstages just a single file

  • --mixed: Uncommit all changes back to the specified location. Take all the changes since them and puts them in the working tree. Nothing is staged. This is a good reset to use if you, say, commit three files, push and realize only one has an error. You can git reset --mixed HEAD^, git add good_1 good_2, git commit "wheat from the chaff", git push origin master. Now your working directory still has the bad file with the bad changes, put you have pushed the good stuff.

  • --hard: Uncommit all changes back to the specified location and throw them away. This is great when you want to forget the last hour, toss all your work and start from square one. Pretty much never the right solution.

  • --soft: Uncommit all changes back to the specific location and stages their files for commit. Very similar to --mixed. In fact this might be better in the case that you commit a lot of files and only one is broken. You can git reset --soft HEAD^ and then git reset HEAD bad_file. Now you have your good changes staged. Commit, push, and deal with the delinquent.

  • --merge: Okay, this one's a little black magic. It's disallowed in many cases because you can't merge with local changes. It's really only used when you merge in changes, the merge fails and you want to undo.

  • --keep: I honestly lost steam by this one. It seems to mainly be used when you git tag things and mess up. I don't really do that so...

  • --patch: This is interactive reset. It's for warlocks. You can interactively select which hunks to leave alone and which hunks to undo. This is a bit of a different paradigm than the other reset commands as you are specifying what to undo rather than what to keep.

Oh cool but what's 'HEAD^'

Carats and tildes are relative commit markers in Git. They both mean "parent" but in a different way.

Most commonly used, they are the same. HEAD^1 (or HEAD^ for short) is the same as HEAD1 (HEAD). Always.

The difference comes when they stack. So HEAD^2 means "The second parent of HEAD". This only means anything if there's been a merge. In a merge, the main branch is parent #1; the merged in branch is parent 2. So, HEAD^2 will be the merged parent, whereas HEAD^1 will be the parent merged into.

Now, HEAD^^ is not the same as HEAD^2. HEAD^^ means the first parent of the first parent. This is just shorthand for HEAD^1^1. Evaluate from left to right.

But HEAD^^ is ugly. Isn't there something nicer. Yes. Tilde. Tilde ALWAYS means first parent. so HEAD~2 == HEAD^^.

Finally, you can stack tildes and carats. Say you wanted the merged in branch of two heads ago. That would be HEAD~2^2. I'm sure you will be doing that a lot. Not really.

Here's a good chart Carat vs Tilde

That's from a good piece on carats and tildes

Great, great. So what about revert.

If reset is rampaging boar running back in time and rooting up changes, revert is a dainty little flower child, skipping back in time and plucking out changes.

It's also real simple.

git revert sha means "commit the opposite of the changes in sha" on top of the tree. So say you made ten commits and three ago you messed one little thing up. git revert HEAD~3 will commit the opposite of that commit on top. You have undone it.

You cannot revert if you have changes in your working directory (it must be the same as HEAD).

More to come

We haven't even gotten to git checkout. But if we've covered the rampaging boar and the dainty flower child, we have definitely run out of attention for the Liam Neeson with a Swiss Army Knife that is git checkout. More later.

@matthewmccullough
Copy link

@esmooov This is fantastic. Would you consider adding it to http://teach.github.com? It's OSS: https://github.com/github/teach.github.com

@dexbarrett
Copy link

@esmooov Great explanation. I have one question though regarding to reset --soft (that confusing command), specifically this part:

You can git reset --soft HEAD^ and then git reset HEAD bad_file. Now you have your good changes staged. Commit, push, and deal with the delinquent.

reset --soft HEAD^ will change the pointer to the parent of HEAD and will stop there, so it won't bring back the changes of HEAD^ back into the staging area. Then, git reset HEAD bad_file (I just want to know if I'm understanding reset wrong) will move the pointer back to HEAD (the latest commit) and since no flag it's being passed it will use --mixed (the default) so it will also pull the changes from HEAD (but only for bad_file) into the staging area so I'm wondering why it would keep only the good changes at the staging area if the second command is bringing the bad file's changes as well into the staging area.

Hope I could explain my question. Thank you.

Edit: I was reading an article by Scott Chacon and I think I figured out why those commands do the job. First of all, I didn't know how "moving the head" actually worked. Internally these commands do the following:

The soft reset moves the head so that it now points to the previous commit but it will leave the index/staging-area untouched and the working tree (files) as well.

The normal reset (--mixed) will pick the changes from the current HEAD and will bring them to the index/staging-area but only for the file that we are specifying. As a matter of fact, when we pass a file to the reset command there's no need to pass the "HEAD" parameter because it will pull the changes from the current position of HEAD by default (when using this mode of reset. See why I say reset is confusing?).

If you run git status after executing these commands you will notice that indeed the good changes are already staged and bad_file is marked as modified. What you can do from here, as you have said, is to commit the good changes, then fix the bad_file and commit again with the corrections.

Yeah, it works and it's very clever, though a little confusing: If we first move the head's pointer and then we reset bad_file to the content of that pointer so that it updates the current index/staging-area, why every other file is marked as staged except the one we're reseting? We are pulling the HEAD's content for this file after all, don't we? The reason is because now the content of the index/staging-area of all the other files is different from the now current HEAD. And why bad_file is reported as already modified? That's because now its index/staging-area no longer contains the snapshot of the commit that had the broken code (we replaced it with the current HEAD's content with reset bad_file) but the file itself does; GIT compares the index and the content of the file and notices they're different so it determines the file has been modified.

Sorry for the long comment but I wanted so share what I learned about reset.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment