Git-fu: useful shortcuts

Originally published Sep 22, 2017

Git is complicated. It's an extremely powerful tool, but it's also easy to get burned -- I've received the dreaded detached HEAD warning on multiple occasions and been at a complete loss as to why that had happened. However, as with any tool, I've continued to learn to use it, and now I'm fairly comfortable performing tasks that a year ago would have stumped me.

Tools

Git is a wonderful tool, but it becomes much easier when you have other tools layered on top of it.

GitHub for Atom

I spend nearly my entire day in Atom, so when they released GitHub for Atom, I was thrilled. Now, I can stage and unstage files as well as individual lines, right from the editor. It's majorly boosted my productivity.

git-plus

This is an Atom package that enables a wide array of Git functionality. While GitHub for Atom mostly "lives" in the right-hand dock, git-plus adds context menu options throughout the UI, as well as commands accessible from the palette. They each have their strengths, and I'm always glad I have both installed.

Sourcetree

Sourcetree is an amazing app that I've been using for years. I almost never use it for common tasks such as committing, merging, and pushing. Instead, what I find useful is that it gives a clear, configurable visual display of the commit graph, and I can right-click on any commit to get its hash. Generally I'll use these hashes for more advanced operations, such as starting an interactive rebase from a specific commit.

You can also cherry-pick or revert any single commit directly from the UI.

Tip

To install on Mac with Homebrew Cask: brew cask install sourcetree

bitbucket-git-helpers

bitbucket-git-helpers is a handy set of aliases useful for quickly opening your browser to Bitbucket pages related to the repo (pull requests, create pull request).

Aliases

Add these under the [alias] section of your ~/.gitconfig to take advantage of them.

git this

This one's super handy: it prints the name of the current branch. Pretty simple, but it comes in handy when working with branches with long names, or chaining together commands.

this = rev-parse --abbrev-ref HEAD

git puot

Push the current branch to an upstream with the matching name. I usually use this for feature branches.

puot = !git push -u origin $(git this)

git cod

I can't tell you how many times I've meant to create a feature branch from develop, but instead accidentally created a feature branch off of whatever feature I was working on at the time. This command prevents me from doing that.

Running git cod feat/foobar creates a new feat/foobar branch from upstream develop. It's usually a good idea to run git fetch before doing this as well, to make sure the new branch is up-to-date.

cod = checkout origin/develop -b

Playbooks

Here are a handful of common tasks.

Figure out what branch I was on

If you're like me, you may switch branches multiple times over the course of a day, but have trouble remembering what branch you were on. To list the 10 branches with most recent commits, run this command:

git branch --sort=-committerdate | head

If you've got aliases set up, you can write a slightly shorter version:

git recent | head

Interactive rebase

This process has immeasurably improved my ability to write clean PRs. As I go, I try to make very small commits. Then, when I'm ready, I perform an interactive rebase, to merge together commits that are too tiny to stand on their own, reword some awkward commit messages, and remove commits that I only made as a quick test.

If I were preparing a PR for the develop branch, I'd run:

git rebase -i --autosquash origin/develop

This would open Vim with a text file resembling the following:

pick 6841ac5 Do not check in this change!
pick 9bd806c Implement feature X
pick 102bda9 Implement feature Y
pick 3011ca5 Fix bug in feature X
pick 9002550 Write tests for feature X
pick e781dae Write tests for faeture Y

As you can see, it's a little rough, and I might not want to check it in this way. I might edit the file to look like this:

drop 6841ac5 Do not check in this change!
pick 9bd806c Implement feature X
fixup 3011ca5 Fix bug in feature X
pick 9002550 Write tests for feature X
pick 102bda9 Implement feature Y
reword e781dae Write tests for faeture Y

The drop command ignores a commit, the fixup command squashes a commit with the previous one, the reword command allows you to rewrite a commit message, and the pick command leaves that commit alone. There are also several other things you can do here; full instructions are available when you run the command.

Save and quit, and Git will roll through the commit history, pausing if there is a merge conflict or to allow you to reword a commit message.

Reverse a merge

First, check out the branch that has the merge you wish to reverse:

git checkout develop

Next, get the ID of the merge commit. I like to do this from Sourcetree by right-clicking a commit and choosing "Copy SHA-1 to Clipboard", but you can also do this with git log.

Finally, run the following command, making sure to paste in the correct hash for $HASH:

git revert -m 1 $HASH

Skip tests for a single commit

I like to have my projects set up to run tests before I push. Sometimes, though, I have to push a branch in an incomplete state, or delete a remote branch while I have active changes. I used to remove the git pre-push hook, perform the push, then re-enable it. Turns out there's a much easier way: the --no-verify flag. For instance, if I want to push some incomplete work on the feat/foo-bar branch:

git push --no-verify -u origin feat/foo-bar

Check out multiple branches

Sometimes, you just need to do more than one thing at a time.

Generally I'll either use git stash or a WIP ("work in progress") commit to put things on hold while I work in another branch. However, there's another command that's useful in some instances where you really do want another copy of the project on your hard drive. The neat thing about this command is that you can work on another copy of a local branch, without having to first push it to your remote.

To create a new working copy of the develop branch, run this command:

git worktree add ../new-working-copy develop

This will check out the develop branch into the ../new-working-copy directory. You can change these arguments as you need to.

One caveat to watch out for: you can't check out the same branch in both your original and new working copy at the same time. This is mildly annoying, but far better than getting your repo into a state where you can't reason about what's going on. If you get an already checked out error after doing this, either change to a different branch in your other working copy, or else delete the working copy and run git worktree prune if you no longer need it.

More info

Listing all tracked files

This one is useful when you're making structural changes to a repo, and want a complete list of all the files that are tracked on a given branch or commit.

To display all files at the tip of the master branch, run:

git ls-tree -r master --name-only

More generally, if you have the git this alias enabled, you can run:

git ls-tree -r $(git this) --name-only

to see all files on the active branch.

Publishing a directory to a branch

This trick is great if you want to publish to a gh-pages branch! Thanks to Ryan Palo for a great post outlining how this works.

Run your build -- let's say it builds into a folder called /public/. Then, make sure this folder isn't ignored via .gitignore, and git add and commit. Then, run the command:

git subtree push --prefix public origin gh-pages

If you're running this in a CI environment, you can keep the built assets out of your working branch if you don't push after committing the built assets. It's a good idea to have the build step modify the .gitignore as well, to continue to prevent people from accidentally committing their built assets.

Pre-empting a merge conflict

Note: I'm not 100% certain about this trick; I'll update this page as I learn more.

When a branch contains a merge conflict, there are traditionally 2 ways of dealing with it.

  1. Merge. This allows you to avoid rewriting git history, at the cost of creating a tangled mess of branches and merges.
  2. Rebase. This allows you to have a relatively linear history, at the cost of having to rewrite history (and possibly irritating teammates). In addition, you'll need to run through the entire set of commits on your branch, possibly with a large number of conflicts.

However, there's a third option: "steal" the changes which create the conflict, to prevent it from happening in the first place.

Here's the magic sauce:

# Note: this is still a work in progress; might break things!
git format-patch ${COMMON_ANCESTOR}^..${MERGE_TARGET} --stdout | git am -3

COMMON_ANCESTOR here is the most recent commit that both branches have in common. You can use a tag for this if it's easiest, or you can pull up a hash using any graphical tool. MERGE_TARGET is the branch you're trying to merge into.

git am will cherry-pick each commit in the set and apply it to your branch. The -3 flag configures am to stop when there's a conflict; if you are already comfortable with interactive rebasing, you'll find the interface familiar.

If you only want to solve the conflict in a subset of files, specify the fileset as arguments to format-patch:

# Note: this is still a work in progress; might break things!
git format-patch ${COMMON_ANCESTOR}^..${MERGE_TARGET} --stdout package.json src/__tests__ | git am -3

Finally, if you want to update the files, but stop before committing, replace git am -3 with git apply -3. This allows you to apply your "merge" as a single commit.

Tip

If you have the git this alias installed, you can automatically find the common ancestor:

# Note: this is still a work in progress; might break things!
git format-patch "$(git merge-base $(git this) $MERGE_TARGET)^..${MERGE_TARGET}" --stdout package.json src/__tests__ | git am -3

Merging packages into a monorepo

A monorepo format can be very helpful when managing large, complex projects. Fortunately, it's fairly easy to combine multiple repositories into a single monorepo.

  1. In each individual repository, move all of the files to where you want them in the final repo. For me, typically this means moving the files from <repo>/* to <repo>/packages/package-name/*.
  2. Add each individual repository as a remote to your monorepo: git remote add package1 $REPO_URL git fetch package1
  3. Merge each individual repository: git merge package1/master --allow-unrelated-histories You may get merge conflicts if you haven't moved all of the files (for example, a root package.json, yarn.lock, or .gitignore). When resolving the conflict, Git will show you both versions of the file. Delete the text from the version you don't want, git add, and commit the merge.

My .gitconfig

You can see my .gitconfig here.