This guide assumes Git version 2.28.0.
You can install it from Homebrew with brew install git
.
The version of Git that comes with macOS is older, so if some of these commands don't work, check git --version
.
A commit is the core unit of change in Git. Each commit adds or removes lines in files.
Every commit has a hexadecimal hash (36ee8abf2
...) that uniquely identifies it among all the other commits in the repository.
git log
shows the full 40-character hash for each commit.
Since 40-character hashes are cumbersome, GitHub often shows just the first 6-9 characters of the hash (36ee8ab
), just enough to identify it uniquely, and hides the rest.
Commits point to their parent commit.
For example, if I make three commits in a repository, A ← B ← C, C would be the most recent commit, B its parent, and C its grandparent.
git log
shows the most recent commit and all of its parents.
Git can reconstruct the repository at any point in time by applying the sequence of added and removed lines in the commit chain. When we make changes by adding commits or merging pull requests, Git looks at those commits to know which lines to add and remove.
In Git, HEAD
is a special alias for the most recent commit.
It's whichever commit git log
would show first.
When we create a new commit, the previous HEAD
commit is the new commit's parent, and then HEAD
is updated to point to the new commit.
Branches are special labels that point to commits.
master
is a regular branch whose label is "master".
Sometimes repositories choose to call it main
instead.
Because we start work by branching from master
, our branches look like a tree with master
as the trunk.
We can follow commits' parents in a chain and eventually end up back in the master
trunk.
We can create our own branches with git branch new-branch-name
.
This will create a new label pointing to the most recent commit.
We can switch between branches with git switch other-branch
.
When we create a new commit, the current branch's label automatically updates from the previous commit to the new commit.
Our laptops, GitHub, and each Heroku environment all have their own copy of our repository.
These repositories keep track of branches and commits separately.
We use git push
and git pull
to copy commits and branch labels between repositories.
git remote --verbose
lists all of the other repositories that Git knows about.
GitHub is typically called origin
, and production
and staging
might be names for Heroku's repositories.
When we push a new branch to GitHub, it pushes all of the commits we've made locally and then tells GitHub to create its own copy of the branch label pointing to the most recent commit.
When we push new commits to an existing branch in GitHub, Git sends GitHub all the new commits that are in our laptop's branch but not yet in GitHub's branch and updates GitHub's branch label to point to the newest commit.
Pulling works in reverse. When we pull a branch from GitHub, Git retrieves all the commits that are in GitHub but not yet on our laptop and updates our laptop's branch label to point to the newest commit.
The master
branch should be the authoritative source for what exists in production.
Changes should only reach master
when the tests are passing and they're ready for production.
We work on in-progress changes in "feature branches" off of master
.
We feature branch might implement a new feature, add a test, change some copy, or fix a bug.
# Make sure we're starting our branch off of master.
$ git switch master
# And make sure we have the latest changes from master on GitHub.
$ git pull
# Create the feature branch from master. Name it something descriptive, like
# `very-cool-feature` or `fix-issue-123`.
$ git branch new-branch-name
# Switch to the new branch.
$ git switch new-branch-name
At this point, we're working on the new-branch-name
branch.
For now, HEAD
, new-branch-name
, and master
all point to the same most recent commit.
Any new commits will be added to new-branch-name
but not master
.
Once we've made a change, we use git add
to prepare the added and removed lines to be turned into a commit.
When we git add
a file, it goes into a "staging" area, which is like a waiting room for the lines that will be in a commit.
# Show what files have been changed since the last commit.
$ git status
# Add a file.
$ git add hello-world.js
# Show that hello-world.js has been added to the commit staging area.
$ git status
# Turn the staged files into a commit.
$ git commit
The git commit
command will open a text editor where you can write a title and message for your commit.
The title is a short description in a few words.
The message contains a more detailed description of what changes we made and why.
If we're adding a new feature, we describe the feature's design and how it's built.
If we're fixing a bug, we describe what caused the bug and why this is the right way to fix it.
Congrats! We've implemented a new feature or fixed a bug. All the steps in the change are nicely packaged as line changes in commits. It's time to open a pull request in GitHub.
# Tell GitHub about our new commits and branch.
$ git push --set-upstream origin my-new-branch
git push
copies all of the new commits to GitHub and creates a copy of our branch label.
origin
is Git's name for GitHub's copy of our repository.
--set-upstream
tells Git to remember that we pushed my-new-branch
to GitHub, so in the future, just git push
will automatically go to GitHub.
Now go to GitHub and fill out your pull request.
We've received review feedback on the pull request and need to make changes. Make commits just like when we were working on the change initially.
# Show the files we've updated in response to feedback.
$ git status
# Add them to the staging area for the next commit.
$ git add hello-world.js
# Commit our review fixes.
$ git commit
# Push the fixed commits to GitHub.
$ git push
Git will send our review feedback changes to GitHub, update GitHub's my-new-branch
branch label to point to the new commits, and show them in the pull request.
In the push
step, since we used --set-upstream
when we first pushed the branch to GitHub, we don't need to specify origin my-new-branch
again because Git remembers those settings.
It's the same as running git push origin my-new-branch
, just with less typing.
Sometimes reviewers leave small suggested changes in review comments. Committing these suggestions to the pull request is quick and easy without ever leaving GitHub. After you commit review suggestions from GitHub, be sure to update your local repository so it knows about the new commits.
# Switch to the pull request's branch.
$ git switch my-new-branch
# Update the local branch with the suggested change commits from GitHub.
$ git pull
If we forget to update our local branch when there are new commits in GitHub's copy of the branch, Git will reject our next push to the pull request.
Never run git push
with --force
.
That would overwrite the suggested change commits from GitHub, losing work.
Git rejected the push because there are commits in GitHub's copy of my-new-branch
that are missing from our local copy of my-new-branch
.
We want to combine the commits from all versions of the branch.
Run git pull --rebase
to take the suggested change commits from GitHub and then replay our local commits after them.
# Git rejects our push because our local branch is missing some commits.
$ git push
To https://github.com/drivecapital/git-workflow.git
! [rejected] my-new-branch -> my-new-branch (non-fast-forward)
error: failed to push some refs to 'https://github.com/drivecapital/git-workflow.git'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. Integrate the remote changes (e.g.
hint: 'git pull ...') before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
# Pull the newer commits from GitHub and replay our local commits after them.
$ git pull --rebase
# Now we can push the combined commits to GitHub.
$ git push
Sometimes GitHub says we can't merge a pull request because we have merge conflicts. This happens when a teammate worked on the same lines in a different branch. A merge conflict is a place where we need to update our change to account for changes already made by a teammate.
First, we need to pull the latest changes from GitHub's master
branch label to our laptop.
# Switch to our local master branch.
$ git switch master
# Retrieve the new commits from GitHub.
$ git pull
# Switch back to our feature branch.
$ git switch my-new-branch
# Re-play all of the commits in our branch on top of newer changes in master.
$ git rebase master
At some point during the rebase
, Git will stop because it came to a commit that changed lines that have more recent updates in master
.
This could be simple if a teammate renamed a variable and our commit still uses its old name.
Perhaps a teammate refactored a method, and our commit wants to change code that's now in a different place.
# Show which files we need to update.
$ git status
Unmerged paths:
(use "git restore --staged <file>..." to unstage)
(use "git add <file>..." to mark resolution)
both modified: hello-world.js
For a contrived example, let's say we have a sayHello()
function.
A teammate just merged a pull request to master
that added a name
parameter to say "Hello" to people by name.
But in our feature branch, we updated it to say "Greetings" instead.
<<<<<< Changes from teammate's commit "Say hello by name" in master
function sayHello(name) {
console.log(`Hello, ${name}!`);
======
function sayHello() {
console.log('Greetings, world!');
>>>>>> Changes from our commit "Greet the world instead" in my-new-branch
}
sayHello('Brandon');
If we take just their change, console.log(`Hello, ${name}!`);
would lose our "Greetings" change.
If we take just our change, console.log("Greetings, world!");
would undo their already-merged change.
We need to manually combine the changes into console.log(`Greetings, ${name}!`);
to include both updates.
# Now that we've resolved the merge conflict, add the file to the commit staging area.
$ git add hello-world.js
# Optionally take a look at our updated changes.
$ git diff --staged
# And continue with the rebase.
$ git rebase --continue
Now we need to send the updated commits and branch label to GitHub.
If we were to run git push
right now, Git would reject the push.
Instead of adding new commits to the branch on GitHub, we want to replace the existing commits with our updated commits.
For this, we use git push --force-with-lease
.
Git will replace the commits, but it will also protect us from overwriting any new commits on the branch.
If we've added commits from review suggestions on GitHub but forgot to pull them down before rebasing, Git will reject the push even with --force-with-lease
because the push would overwrite the suggestion commits.
We need to start over and pull the suggestion commits before performing the rebase.
# Switch to our feature branch.
$ git switch my-new-branch
# Start over back where the pull request is.
$ git reset --hard origin/my-new-branch
# Pull any new commits from the pull request.
$ git pull
# Now re-do the rebase on master.
$ git rebase master
# After resolving merge conflicts and finishing the rebase, update the pull request.
$ git push --force-with-lease
Good Git usage reflects empathy for code reviewers.
A pull request is easiest to review when it has one focus. A single-commit pull request with a three-line styling change and before and after screenshots in the description takes no time at all to review. Better yet, it won't be open long enough for another change to create a merge conflict.
Implementing features will never be that simple, but keeping them focused will pay off in less painful code reviews. If we stumble across an existing bug while working on a feature, putting the fix in its own pull request will have it fixed in production before we're ever done implementing the feature. The feature pull request will be easier to review and less likely to encounter a merge conflict because it no longer includes an unrelated change.
# Found a bug while working on cool-feature!
# Stash changes to come back to them later.
$ git stash
# Head back to master and check for newer changes.
$ git switch master
$ git pull
# Start a new branch for the bug fix.
$ git branch fix-bug
$ git switch fix-bug
# After fixing the bug, commit the change, push the branch, and open a pull request.
$ git add fixed-file.js
$ git commit
$ git push --set-upstream origin fix-bug
# Switch back to cool-feature and unstash changes to pick up where we left off.
$ git switch cool-feature
$ git stash pop
If a feature is particularly large, we can break it up into multiple pull requests. The first pull request could include the model, migration, and API endpoint. While that's in code review, we can keep working on a second pull request to use the new API from the client. This prevents individual code reviews from taking weeks of back-and-forth talking about different parts of the changes.
# We've finished part one of our feature. Create a pull request.
$ git switch feature-part-one
$ git push --set-upstream origin feature-part-one
# Start a new branch starting from part one's branch label.
$ git branch feature-part-two
$ git switch feature-part-two
# ...
# Keep committing to feature-part-two.
# ...
# When feature-part-one is approved and merged to master, rebase part two on top of master.
$ git switch master
$ git pull
$ git switch feature-part-two
$ git rebase master
Advanced tools like git cherry-pick
can help pull bug fixes or refactors from larger branches into their own standalone pull requests.
The development process is messy and non-linear. We often iterate to a solution with false starts along the way, but reviewers care more about where we ended than the roundabout way we got there.
The ideal pull request tells a linear story where each commit is a logical step. The reviewer doesn't need to know that we wrote a serializer, then we started building the client, then we realized we missed a serializer field, oh and then we found a typo in a variable name. Alternatively, finishing a feature and then leaving one "big bang" commit at the end leaves the reviewer to find their own way through how it works. When each commit is a logical step, we can guide the reviewer through the implementation.
When we have un-committed changes in a file that we want to commit as multiple steps, we can commit only part of a file to pick changes for each commit.
If we want to go back and tweak a step that was already committed, we can fix up a prior commit by combining more added and removed lines into the original commit.
If we're done working but decide the story makes more sense with the steps in a different order, we can re-order commits.
If we realize a commit does too many different things, we can split it up into individual commits.
If we just merged and deployed a pull request that broke everything and we can't fix it in a hurry, the revert button is the nuclear option.
Clicking the "revert" button on the offending pull request will reverse all of its changes, removing the lines it added and adding back the lines it removed.
If we do a good job submitting pull requests that make logically grouped changes and splitting unrelated changes into their own pull requests, we should be able to revert just the pull request that caused the breakage.
We shouldn't need to use the git revert
command locally.
Each commit should do one thing.
If we've fixed two unrelated bugs, we'd like to create two commits, one to fix each bug.
We don't have to git add
an entire file at once. Instead, we can add only part of a file.
# Start an interactive session to stage lines for the commit a chunk at a time.
$ git add --patch
git add --patch
will go through the changes one chunk of lines at a time.
Typing ?
at the prompt will show help instructions.
y
will stage this chunk of lines for inclusion in the next commit.
n
will leave this chunk of lines unstaged for a later commit.
If the chunk of lines is too big, split it into even smaller chunks with s
, then stage or skip the sub-chunks with y
or n
.
If you've added a file to the commit staging area and don't want it to be part of the commit, you can remove it from the staging area before committing.
# Show the files in the staging area for the next commit. Whoops, hello-world.js
# isn't supposed to be part of this commit! Let's leave it for a later commit.
$ git status
# Remove hello-world.js from the staging area.
$ git unstage
# Show that hello-world.js has been removed from the commit staging area. A new
# commit now would not include the added and removed lines in hello-world.js.
$ git status
TODO
TODO
TODO
TODO
When pulling from GitHub, only allow "fast forwards". When "fast forwarding", if your local branch contains commits A, B, C, and GitHub has A, B, C, D, E, pulling will add D and E to your local branch.
$ git config --global pull.ff only
Enable branch protection in your GitHub repository's settings:
master
- Don't allow force pushes
- This only applies to
master
. - We can still
--force-with-lease
to feature branches in advanced uses.
- This only applies to
- Don't allow deletions
- Include administrators
- Prevents even organization owners from accidentally force pushing to
master
. - If 💩 hits the fan and you really need to, you can temporarily un-check this.
- Prevents even organization owners from accidentally force pushing to
- Optionally, require pull request reviews before merging
- Changes must go through code review.
- Prevents accidentally pushing directly to
master
.
- Optionally, require status checks to pass before merging
- If your automated tests aren't flaky.
- Optionally, require linear history
- Forces people to resolve conflicts via rebasing their feature branch on
master
instead of merge commits.
- Forces people to resolve conflicts via rebasing their feature branch on
- Don't allow force pushes
GitHub will automatically clean up branches after merging pull requests if you enable "Automatically delete head branches".