Want to see the full-length video right now for free?
Sign In with GitHub for Free AccessOne of the most powerful features of Git is how it separates the acts of coding and version control. There's no central server to sync with, no locking files so you teammates can't edit them while you do, in fact there is almost nothing required before you start coding.
Instead, you can simply go to work, coding and figuring things out as comes natural. Whenever you're ready you can then focus on version control and commit your changes.
While this kind of freedom is invaluable, it doesn't always lead to the most straightforward history. Luckily for us, Git provides many ways to craft our history, arranging it to tell the story of building a feature as we'd want, rather than being stuck with the wandering sequence of changes we initially created.
In this video we'll cover these techniques, including selective staging, cherry picking, and all forms of rebase.
Often when we're ready to commit our changes, we'll run git add .
or git
add --all
, but these are a bit coarse. We can use the more focused form
where we name files or directories to stage, for instance git add Gemfile
,
but even this can be too coarse if we have distinct change sets within a
single file.
Instead, we can use the --patch
flag to tell Git that we would like to
review each group of changed lines individually, and choose whether or not to
stage them. The full command is run as:
$ git add --patch
When running git add --patch
, Git will split the changes in each file into
"hunks" and present them one at a time, prompting you for how to proceed. The
primary operations are to stage the hunk, skip it, or split it into smaller
hunks if possible. Below is a collection of some of the more common
operations (this list can also be seen by typing h
at the prompt):
Key | Operation |
---|---|
h |
Display the list of available keys and their operation |
y |
Stage the current hunk |
n |
Skip this hunk |
s |
Split the hunk |
a |
Stage this and all remaining hunks |
q |
Quit, skipping all remaining hunks |
e |
Edit the hunk manually, allowing for line be line staging |
In some cases we'll find that we've made a commit on the wrong branch. We might
have checked out master
and made changes there, rather than our feature
branch. Luckily Git provides a command, cherry-pick
, that allows us to copy
commits onto a different branch.
Assume we have two commits made to our master
branch and we want to move
them onto our sanitize-search-query
feature branch. We can do this by first
identifying the commits we want to bring across. We could specify them by
their commit hashes, but an easier option is to reference them as the range
from origin/master
up to master
. We can confirm that we've specified the
range properly by running either git diff
or git log
with the range:
$ git diff origin/master..master
# confirm that the diff contains all the expected changes
or
$ git log origin/master..master
# confirm that only the expected commits are listed
From there, we can check out our feature branch and run the cherry-pick
command, passing the range as our argument:
$ git checkout sanitize-search-query
$ git cherry-pick origin/master..master
# git will create a new commit for each in the range and output a summary of
# the new commits
Note, with the above cherry-pick
operation, as with all Git operations, Git
does not destroy or edit commits, but instead simply creates new ones. This
means that the two commits as authored are still on our master branch.
We can solve this by reseting our master branch to align it with
origin/master
, essentially erasing the original commits (although Git still
remembers just in case; Git's got our back).
$ git checkout master
$ git reset --hard origin/master
Now that we've seen cherry-pick, we can tackle the oft-dreaded rebase. It turns out that a standard rebase is essentially identical to the cherry-pick workflow that we've just shown.
When we rebase, we do it from the context of our feature branch, and we specify the target branch we want to to rebase onto. So the command name "rebase" actually does a great job of explaining what the command does; it "re bases" or bases again. We want to take the work we've done on our feature branch, and reapply it as if it was done on top of the additional commits in our target branch.
As an example, let's assume we have a branch that when started was based off of
master
. We've made some changes on our branch and now have two new commits,
but at the same time our colleagues have also made changes on the master
branch.
$ git branch
* faq-updates
master
$ git rebase master
When performing the rebase, Git finds the commits unique to our branch and
computes the diff off the changes they introduced, then moves to the target
branch, master
in this case, and one by one applies the diffs, creating new
commits reusing the commit messages from our branch. Once done, it updates
our branch to point at the newest of these commits created by reapplying the
diffs.
And now we're ready to move on to our final form of history crafting which is the surprisingly-powerful interactive rebase command. Unfortunately it shares a name and command with the standard rebase we just reviewed, but for practical purposes it's actually quite different.
With interactive rebase we're not moving our commits to a different point in history, but instead revising the given range of commits in place. The most common usage is to combine or squash commits together, but you can even go so far as to entirely delete or reorder commits.
Some folks will say that revising history is an unforgivable sin, but we here at thoughtbot consider it an important part of our workflow. While we would never revise published history, specifically the master branch, we almost always revise our commits on feature branches before merging them in. We value a clean history, and the majority of the time, the commits in a feature branch contain many rounds of refactoring and PR reviews which we don't want in the permanent history. Instead, we want the most direct and concise form of the history that fully captures the change we settled on in our feature branch after completing any refactoring or updates.
To demonstrate, we'll again come back the faq-updates and can see that the history currently is as follows:
$ git log --oneline --decorate -5
* 009ac91 (HEAD -> faq-updates) Add other file
* c0a3941 Remove line in README
* be891be WIP add forum answer to FAQ
* a915fac (origin/master, origin/HEAD, master) Add a search page using...
* 85ff650 Update skylight gem
From this view, we can see that we have 3 commits on on our faq-updates
branch, and they are directly ahead of master
. Using interactive rebase, we
can squash these down into a single commit:
$ git rebase -i master
Git will then open our editor with a file for us to edit, similar to how commit messages are composed, and in this file the commits we can operate on are presented, one per line, in reverse chronological order. We can then choose from an array of operations, but the most common is simple to "squash" the commits below the first commit.
pick be891be WIP add forum answer to FAQ
s c0a3941 Remove line in README
s 009ac91 Add other file
Squashing has the effect of keeping the working tree for a commit, but folding the commit object itself back into the previous so we are left with only a single commit for which the working directory is taken from the last of the "squash" commits.