People use Git in a myriad of ways and many develop their own personal workflows. Flexibility is one of Git’s strengths, but that can also make collaboration harder. Establishing some principles or best practices, for yourself or your team, will increase productivity, save time and avoid losing work.

I’ve been using Git for almost two decades and learned the ins and outs of it, and I’ve used it in both small and large (100+ dev) teams. This led to the discovery that many teams don’t have established workflows which can really wreak havoc on a team. Over time I’ve seen what works and I wanted to share my workflow to make collaboration easier.

I distilled this workflow down to 4 principles and 6 practical tips, and if you follow them Git is a delight! Especially when you’re working on a team.

I hope you find them useful, let me know in the comments below or tell me what your flow looks like.

Here’s what we’ll be covering.

Principles

  1. Always work on a feature branch
  2. Take deliberate actions
  3. Never force push
  4. Squash your git history clean

How to and tips

  1. Simplify your commands
  2. Progressive git adding (patch add)
  3. Amending and fixing commits
  4. Keep a clean history with interactive rebasing
  5. Save your hard work with git stash
  6. Get out of sticky situations with cherry-pick

In the examples below I use the command line Git tool but these concepts should work with most graphical interfaces. Otherwise, now is a great time to learn these commands.

Principles

Always work on a feature branch

Pushing direct to the main branch should be both discouraged and disabled. If you push to the main branch you are opening up to accidental bugs or worse - malicious attacks.

When it’s time to work on a new feature, first make sure you have the latest upstream changes, then check out a new branch. Having a routine like this minimises the risk of errors when working on your changes.

Here’s how I start work on a new feature:

# First make sure we're up-to-date
git checkout main
git fetch
git rebase origin/main
# Then create a branch
git checkout -b acr/feature-name

I always prefix my branches with a personal identifier or username. This makes it easier for me to find my old branches and easier for colleagues to know who owns it.

In an ideal world we always start working from the main branch like in the example above. I’ve forgotten to fetch the latest changes before starting new work so many times and if you do, it’s not hard to correct. Use git stash to save your changes, follow the example above, then apply your changes with git stash pop.

Github flow goes into further detail about this process if you want to learn more.

Take deliberate actions

Working with Git you always want your actions to be deliberate to avoid errors and bad states. Git has convenience methods like pull which basically performs the fetch and merge steps. But what happens if you have local changes, or you’re the wrong branch? Simple examples perhaps but it’s easy to end up in more complicated situations.

That’s why we want to be deliberate about our actions. Try to be as specific as possible when working with Git, and if something goes wrong it’s one thing you need to fix. I can’t even count how many times being deliberate has saved me from complicated merges and rebases.

A couple of practical tips on being more deliberate.

Prefer fetch over pull

The fetch command only fetches the remote changes and stores them in the local database. The pull command first does a fetch then merges your local changes with the remote.

Using fetch instead of pull puts you in control over when and where you want those changes merged, like merging a different branch into yours.

Always using fetch is like muscle memory for me, and allows me not to worry about how the merge will be even with local changes.

Prefer merge over rebase

If you’re working on a feature branch for a longer period of time changes are likely to occur on the main branch. To keep your feature branch up-to-date, fetch and merge them.

From your feature branch:

git fetch
git merge origin/main

Faster and easier than checking out main, pulling, and rebasing the feature branch to main. It’s also much clearer what’s happening. Deliberate.

Using merge means we only have to deal with one potential merge conflict. It’s easier than having many small conflicts, or addressing the same conflict several times. It also avoids rewriting the git tree like a rebase does.

I only use rebase to catch up on the main or a shared branch where we want to preserve the git tree.

If you’re worried this leads to more typing, stay to the end where I share my shortcuts to make this fast and easy.

Never force push

It really surprised me to find out that a lot of people use force push. There are very few legitimate reasons for using force push but the risks are very high. You don’t really need it! Thanks to the principles and tips in this article I can’t even remember the last time I needed it.

Using force push opens up for a range of possible problems:

  • it’s easy to mess up the git history and cause huge problems for collaborators
  • risk overwriting an unexpected change or conflict, causing bugs
  • it makes collaboration and receiving help from coworkers harder
  • code review tools have trouble with it, especially partial reviews

Same as always using fetch, making it a rule helps.

If you never force push, you never get comfortable using it!

Reserve force pushing for emergency situations, like removing sensitive information or correcting errors.

A common reason to force push seems to be rebasing the feature branch onto the latest main branch. To avoid this issue, instead of rebase use merge and pushing will work without force.

Squash your git history clean

Keeping a clean git tree helps with release management, reverting changes, finding bugs and much more. It’s one of the reasons why rebase is so common, but here’s where squash merging comes in.

When we squash merge we clean up the commit history by merging all commits on a branch into one commit on main. No traces of “fix this” or “attempt that”. With squash merge we get one commit per feature branch making it easy to revert if there was a problem. Feature branches you can delete after merging to master to keep the git tree looking nice and clean.

I typically use Github to squash a branch through a pull request, but here’s how you do it on the command line:

git checkout main
git merge --squash {feature-branch}
git commit -m "{Commit message explaining the feature}"
git push

How to put the principles to use

These principles might seem limiting at first, but your work will be easier and you’ll spend less time fixing issues. I rarely have complicated conflicts, branching issues or git history errors.

To help you get out of any sticky situations, maintaining a clean history and avoiding loss of work, here are some tips.

Simplify your commands with aliases

Since I use the command line and Git is one of my core tools I want things to go quick and be easy. To save a few characters every time I want git, I shortened it down to just g.

Depending on which shell you use edit your ~/.profile, ~/.zshrc, or ~/.bashrc file and add:

alias g='git'

I also have aliases for common operations like:

alias gs='git status'
alias gd='git diff'
alias ga='git add'
alias gc='git commit'
alias gp='git push'
alias grom='git rebase origin/main'

Finally, to view my git tree/history I have a command I call “graph”:

git log --all --decorate --graph --pretty=format:'%C(yellow)%h %C(red)%ad %C(blue)%an%C(green)%d %C(reset)%s' --date=short --abbrev-commit

I call this git graph (or g for short) in ~/.gitconfig, and I have a terminal command alias gg in ~/.profile.

I’m so used to using g, I found it hard to type out git throughout this article!

At the bottom of this article you’ll find all these aliases in one place.

Progressive git adding (patch add)

To make sure I only add the changes I want I always add my changes in chunks. Git calls this patching, which I find confusing, so I call it “adding progressively”, as in “steadily in stages”, or gap for short. Adding your changes this way also forces you to look over all the changes, performing a self-review as you go.

git add --patch

This will start stepping through your changes patch-by-patch and allow you to add or ignore them. You can also split a patch into smaller ones, or pick specific lines you wish to add. Use the built-in help command once you start.

Patching will ignore new or removed files, so don’t forget to check that you have all your changes before committing.

I shorten this command in the ~/.gitconfig to git ap, and I even have a command line alias for that:

alias gap='git add --patch'

Amending and fixing commits

If you noticed a typo or forgot a file after committing there’s a nice trick to fix that while avoiding a second commit.

First stage your changes through git add then run:

git commit --amend

If you don’t need to change the original commit message use:

git commit --amend --no-edit

I aliased this to git fix in ~/.gitconfig.

IMPORTANT CAVEATS

  1. This only works on the most recent commit.

  2. DO NOT use it if you already pushed your code to the server. Your local history will diverge from the remote and you’ll have to use force push.

If you can’t amend or fix your commits, remember to squash merge when merging your feature branch back to main.

Improve messages and history with interactive rebasing

Interactive rebase enables you to edit old commit messages, or merge commits into one.

git rebase -i HEAD~4

Where 4 is the number of commits you want to edit/list. This will bring up an editor where you can choose to edit or merge those four latest commits.

Save your hard work with stash

Misstakes happen all the time, even after years of practice. What’s important is knowing how to get yourself out of a sticky situation.

Enter git stash, a life saver for git misstakes. Stash saves all your local changes (except new or removed files) and removes them from your code. This allows you to make changes to your git history, change branches and more without losing that work.

To save your work type:

git stash

To recover your work there are a two options:

git stash apply

Which re-applies your saved changes, and keeps the saved copy of the changes for later use (git stash clear will remove it).

Then we have:

git stash pop

Which applies the changes and removes the saved copy in one go.

But there is actually a third and valuable way of recovering a stashed change. Stash saves your changes as commits on a special branch. You can find this branch and commit in the git tree to recover the changes. Use the git graph command line tool (from earlier) to locate the branch named (refs/stash). Once you found the commit grab the SHA, inspect it with git show {COMMIT SHA} and apply the changes with a cherry-pick git cherry-pick {COMMIT SHA}.

Get out of sticky situations with cherry-pick

It’s surprisingly easy to mess up a branch like committing a change to the wrong branch. If you do, cherry-pick is another life saver to remember.

Lets say I started some work on a new feature, on the wrong branch… First idea might be to stash, rebase and re-applying the stashed work on the correct branch. But sometimes stashing can be finicky across branches or cause confusing conflicts. If you run into problems try cherry-picking instead:

  1. First check out a new temporary branch
  2. Commit the changes
  3. Switch to the correct branch
  4. Cherry-pick the commit to recover it.
  5. If needed, reset the previous branch to its original state.
git add --patch
git commit -m "Temp stash"
# Copy the commit SHA from commit
git checkout other-branch
git cherry-pick {commit SHA}
# Assuming you made an error on main:
git checkout main
git reset --hard upstream/main

Bonus: My Git config

I’ve been collecting helpful git commands for a long time and thought I would end this post with sharing them. It’s also available through my configuration repository on Github.

My favorite aliases from ~/.gitconfig, they are used with the git (or g in my case) command, e.g. g aa:

a = add
c = commit
f = fetch
r = rebase
w = show
d = diff
p = push
s = status
t = stash
b = branch
l = log
g = log --all --decorate --graph --pretty=format:'%C(yellow)%h %C(red)%ad %C(blue)%an%C(green)%d %C(reset)%s' --date=short--abbrev-commit
aa = add --all
ap = add --patch
amend = commit --amend
fix = commit --amend -C HEAD
cp = cherry-pick
what = whatchanged
dom = diff origin/master
graph = log --all --decorate --graph --pretty=oneline --abbrev-commit
co = checkout
pr = pull --rebase
rom = rebase origin/master
riom = rebase -i origin/master

My favorite Git related aliases from ~/.profile

alias g='git'
alias gs='git s'
alias ga='git add'
alias gap='git ap'
alias gadd='git add'
alias gc='git commit'
alias gco='git checkout'
alias gw='git show'
alias gd='git d'
alias gl="git l"
alias gf='git f'
alias gdc='git dc'
alias gdo='git do'
alias gg='git g'
alias gp='git p'
alias gb='git symbolic-ref HEAD'
alias gr='git rebase'
alias gri='git rebase -i'
alias grom='git rebase origin/main'