A simple Git workflow - using main as the development branch

Published on 2021-09-02.

One of the main benefits of Git is that it is an extremely flexible set of tools with which you can build many development models and branching strategies from. In this tutorial I'll show you my favorite Git workflow that works very well both as a solo developer and in a team with many developers.

Table of contents

My favorite Git workflow

Before we begin, if you're new to Git I highly recommend these three small videos about Git. A lot of the tutorials on YouTube is either ridicules irrelevant or made by people who themselves have just started out. These three videos are made by someone called David Mahler and they are very explanatory, simple and straight to the point.

  1. Introduction to Git - Core Concepts
  2. Introduction to Git - Branching and Merging
  3. Introduction to Git - Remotes

Git can be quite overwhelming because it is a complex beast. However, you will find that you will never need any of the many complex features if you stick to a simple branching workflow.

Git has a default branch called main. In this workflow we will consider the main branch as the branch where all the main development occurs. The main branch is your development branch, it is where the "bleeding edge" of your software lives.

Some people advice against using the main branch for development, and they advice to create a non-default branch named something like develop, but in my humble opinion, using the main default branch for development and staying as close to the main branch as possible has many benefits.

I don't like the idea that somebody must check out a special branch after cloning the repository in order to start developing. If you keep the main branch as the default development branch, people can start working on the bleeding edge code right away. When people download the repository they should not believe that they can simply compile or build the main branch and it will work. If they need to compile or build the software, they need to checkout the latest release branch.

When you work on a project with several other people you will soon discover that the more public branches you have got, the more stress you get at maintaining all these branches. Stay as close to the default main branch as possible.

Whether you work alone or with a team one of the important rules of the workflow described in this tutorial is that you must implement "a daily merge routine". You either push to the main branch of the repository or pull into the main branch on the repository on a daily basis. This is important because on a very busy development branch with a lot of work a lot of merge conflicts can occur. If you wait too long with merges they become difficult to sort out. If you implement the simple rule that you must merge to the main branch daily, most issues are solved before they become too difficult to address.

At some given point in time you create a code freeze by creating a release branch. You can do this based upon some fixed schedule, like once every six months, or you can do it whenever you feel that you have reached a point when a new version of the software requires a fresh release. A code freeze is a point in time in which the development process is halted and it helps move the project forward towards a release or the end of an iteration by reducing the scale or frequency of changes, and may be used to help meet a road map.

When you have decided to do a code freeze you create a release branch called something like release-1, where the "1" number is the major number in your version numbering. Every major release hence forward requires a feature freeze. The release branch always only get bug fixes, security fixes and minor updates (like fixing typos). No new features are ever added to a release branch and no features are ever removed from a release branch.

A bug fix commit in the release branch must fix only the bug, it must never add or remove features. If it seems impractical for the task at hand, make it practical by breaking up the bug ticket into smaller elements. Lock release branches when you're ready to stop supporting a particular release. If you need to add or remove features, create a new release branch for the next release of the software.

Since the main branch is where the bleeding edge development lives it may not always build cleanly and it may occasionally suffer from breakages as new features are added and debugged, but the code in your relevant release branch(es) should always pass all tests and build cleanly.

If you plan to test out a new feature or something radically different from what is located in the main branch, you can make an experiment branch for that. If the experiment works out, merge it into main and delete the experiment branch. If it doesn't turn out to be a good idea, simply delete the experimental branch.

As a general rule, no matter what branch you work on, always make small incremental commits because they are easier to merge and easier to review than one single big commit of work with a huge amount of changes or new code.

The version numbering schema I follow is related to Semantic Versioning for APIs, with some differences. I use the version numbering MAJOR.MINOR.PATCH with the following definition:

This is then how the Git branching and tagging might look like:

Branches and tags are cheap in Git, there is no reason to avoid them.

If the software is being used in production, it should already be version 1.0.0. Everything in 0.x.x is in alpha or beta stage. Everything from 1.x.x and forward is a stable release.

A pre-alpha release refers to all activities performed during the software project before formal testing. An alpha release refers to the first phase of software testing. In this phase, developers generally test the software using white-box techniques. A beta release refers to a feature complete release but it is likely to contain a number of known or unknown bugs. Software in the beta phase will generally have many more bugs in it than completed software and speed or performance issues, and may still cause crashes or data loss.

If a database is related to the project you're working on, it can be really helpful to keep a dump of the database schema in a separate database branch in the Git repository as well. A SQL dump of the database schema, with or without some data, is just a text file. If the database schema changes simply tag it so it matches the software release tag.

A radical simplified version

If you work as a solo developer on a project, or on a project with only a small team, you can simplify the branching workflow by completely avoiding branches all together by only using the main branch and tagging.

In the simplified version you still do all the development on the main branch. When you reach a release you simply freeze the code, tag the main branch, and then continue working on the main branch.

If you need to fix a bug in the released version, Git allows you to checkout the tagged version of the software, fix the bug and commit the fix, and then tag the fix. Then afterwards you checkout the main branch and continue your work. It will then look like this:

Squash the typos

One of the many benefits of Git is that it gives you a great amount of freedom to do whatever you want. I am personally not in favor of keeping unimportant commits laying around in the commit history. Every typo and every spelling mistake that has been fixed doesn't need to go into the commit history as it is completely unrelated to the code. The commit history should display the relevant programming or structural changes to the code, not every minor typo or spelling mistake.

If you have checked out a release branch and commited several typo fixes, feel free to squash all of those commits into a single commit before you push or pull them to the main repository.

However, never squash small code commits into a single big commit. The code is easier to review and evaluate when it consists of small code commits.

Merging conflicts are always hard

A merge conflict, or merging conflict, happens when you (or several people) have edited the same file in the same place. Some people think that when you use a version control system (VCS), such as Git, you somehow get a magical solution to merging conflicts, but that is not true at all. Git can make normal merge very easy, but merging conflicts always requires that someone sits down and spend time figuring out how to solve the problem.

A simple merge is not a problem because the same file was not edited in the same place, and it is relatively easy to fit in the changes to the file. However, when a merging conflict arises, the version control system has no way to know which changes are relevant to keep and which need to be deleted. If a file has been updated the same place, or multiple places by multiple people making the combined changes intermingle, it can easily become quite difficult to merge the changes. A merging conflict always requires manual work.

A small demo

In this section I'll setup a very simple demo of the workflow described above, including a small merging conflict. Please consider that this is a very simple example.

We begin by creating a new Git repository.

$ mkdir our-project
$ cd our-project
$ git init
Initialized empty Git repository in our-project/.git/

Let's check the status of our newly created Git repository:

$ git status
On branch main

No commits yet

nothing to commit (create/copy files and use "git add" to track)

Let's add a file and put some data into it.

$ echo "Hello" > foo.txt
$ git add foo.txt
$ git commit -m "Add a file"
[main (root-commit) 5bd3a68] Add a file
 1 file changed, 1 insertion(+)
 create mode 100644 foo.txt

Then let's update the file a couple of times.

$ echo "world" >> foo.txt
$ git commit -a -m "Add message to the world"
[main 2f9f135] Add message to the world
 1 file changed, 1 insertion(+)

$ echo "I like UNIX" >> foo.txt
$ git commit -a -m "Add UNIX message"
[main f0add60] Add UNIX message
 1 file changed, 1 insertion(+)

Let's take a look at our log.

$ git log --oneline --graph --all
* f0add60 (HEAD -> main) Add UNIX message
* 2f9f135 Add message to the world
* 5bd3a68 Add a file

At this time our "software" is ready for the first release. It looks like this:

Hello
world
I like UNIX

Let's create a release branch and tag it.

$ git branch release-1
$ git checkout release-1
Switched to branch 'release-1'

$ git tag 1.0.0

Let's have a look at the log.

$ git log --oneline --graph --all
* f0add60 (HEAD -> release-1, tag: 1.0.0, main) Add UNIX message
* 2f9f135 Add message to the world
* 5bd3a68 Add a file

Now it's time to get some more work done, let's continue development on the main branch.

$ git checkout main
Switched to branch 'main'

$ echo "OpenBSD is a great operating system" >> foo.txt
$ git commit -a -m "Add message about OpenBSD"
[main e0af16d] Add message about OpenBSD
 1 file changed, 1 insertion(+)

$ echo "FreeBSD is also a great operating system" >> foo.txt
$ git commit -a -m "Add message about FreeBSD"
[main b30dfc5] Add message about FreeBSD
 1 file changed, 1 insertion(+)

Let's check the log.

$ git log --oneline --graph --all
* b30dfc5 (HEAD -> main) Add message about FreeBSD
* e0af16d Add message about OpenBSD
* f0add60 (tag: 1.0.0, release-1) Add UNIX message
* 2f9f135 Add message to the world
* 5bd3a68 Add a file

Let's now assume that we have found a bug in the released version of the software. The word "Hello" and the word "world" are located on separate lines, this is a bug, they need to be located on the same line. Let's fix that and then check the status.

First we checkout the release branch.

$ git checkout release-1
Switched to branch 'release-1'

Then we fix the bug.

$ vi foo.txt
$ cat foo.txt
Hello world
I like UNIX

Let's commit the bug fix.

$ git commit -a -m "Fixed the hello to the world"
[release-1 98812a2] Fixed the hello to the world
 1 file changed, 1 insertion(+), 2 deletions(-)

Time to tag the release branch again.

$ git tag 1.1.0

Let's take a look at the log again.

$ git log --oneline --graph --all
* 98812a2 (tag: 1.1.0, release-1) Fixed the hello to the world
| * b30dfc5 (HEAD -> main) Add message about FreeBSD
| * e0af16d Add message about OpenBSD
|/
* f0add60 (tag: 1.0.0) Add UNIX message
* 2f9f135 Add message to the world
* 5bd3a68 Add a file

We have also found another bug, the word "UNIX" needs to be changed to "BSD and Linux". Let's do that and commit that too.

$ vi foo.txt
$ cat foo.txt
Hello world
I like BSD and Linux

$ git commit -a -m "Fix UNIX"
[release-1 75ce4ac] Fix UNIX
 1 file changed, 1 insertion(+), 1 deletion(-)

We need to tag that bug fix as well.

$ git tag 1.2.0

Let's have another look at the log.

* 75ce4ac (HEAD -> release-1, tag: 1.2.0) Fix UNIX
* 98812a2 (tag: 1.1.0) Fixed the hello to the world
| * b30dfc5 (main) Add message about FreeBSD
| * e0af16d Add message about OpenBSD
|/
* f0add60 (tag: 1.0.0) Add UNIX message
* 2f9f135 Add message to the world
* 5bd3a68 Add a file

We should have merged these bug fixes into the main branch right away, but for the sake of simplicity, and in order to create a little merging conflict, I have put it of. We need to merge our bug fixes from our release-1 branch into the main branch as those bugs still effect the software in main.

First we'll checkout the main branch and then have a look at the log.

$ git checkout main
$ git log --oneline --graph --all
* 75ce4ac (tag: 1.2.0, release-1) Fix UNIX
* 98812a2 (tag: 1.1.0) Fixed the hello to the world
| * b30dfc5 (HEAD -> main) Add message about FreeBSD
| * e0af16d Add message about OpenBSD
|/
* f0add60 (tag: 1.0.0) Add UNIX message
* 2f9f135 Add message to the world
* 5bd3a68 Add a file

We can see that we have currently checked out the main branch by the location of HEAD (HEAD serves as a pointer to your current checkout) and that the release-1 branch has diverged.

Let's figure out what the difference between the main branch and the release-1 branch is by using the git diff command.

$ git diff release-1
diff --git a/foo.txt b/foo.txt
index 6e21339..eb029f2 100644
--- a/foo.txt
+++ b/foo.txt
@@ -1,2 +1,5 @@
-Hello world
-I like BSD and Linux
+Hello
+world
+I like UNIX
+OpenBSD is a great operating system
+FreeBSD is also a great operating system

This shows us that the main branch is missing the first two lines that begin with a dash "-" sign, while the release-1 branch is missing all the lines that begin with a plus "+" sign.

Since the places where editing has occurred in the file in both the main branch and the release-1 branch are the same, Git cannot figure out which branch contains the correct version and do a simple "fast-forward". A merging conflict will arise that we need to solve manually.

When you try to merge one commit with a commit that can be reached by following the first commit's history, Git simplifies things by moving the pointer forward because there is no divergent work to merge together. This is called a "fast-forward".

Let's merge the changes from the release-1 branch into the main branch.

$ git merge release-1
Auto-merging foo.txt
CONFLICT (content): Merge conflict in foo.txt
Automatic merge failed; fix conflicts and then commit the result.

Git is telling us that it could not merge the changes automatically and that a conflict has occurred. If we take a look at the foo.txt file we'll see the conflict.

$ cat foo.txt
<<<<<<< HEAD
Hello
world
I like UNIX
OpenBSD is a great operating system
FreeBSD is also a great operating system
=======
Hello world
I like BSD and Linux
>>>>>>> release-1

From the placement of "HEAD" to the line marked with the equal signs "=======", we see what is located in the main branch, while the content below the line marked with the equal signs is from the release-1 branch. We will now manually edit the file and make it right.

This is what the file looks like after we have manually fixed the conflict.

$ cat foo.txt
Hello world
I like BSD and Linux
OpenBSD is a great operating system
FreeBSD is also a great operating system

Let's look at the status.

On branch main
You have unmerged paths.
  (fix conflicts and run "git commit")
  (use "git merge --abort" to abort the merge)

Unmerged paths:
  (use "git add ..." to mark resolution)
        both modified:   foo.txt

no changes added to commit (use "git add" and/or "git commit -a")

Time to commit the solved merging conflict.

$ git commit -a -m "Merge bug fixes from release-1"
[main eed8406] Merge bug fixes from release-1

Let's look at the log.

$ git log --oneline --graph --all
*   eed8406 (HEAD -> main) Merge bug fixes from release-1
|\
| * 75ce4ac (tag: 1.2.0, release-1) Fix UNIX
| * 98812a2 (tag: 1.1.0) Fixed the hello to the world
* | b30dfc5 Add message about FreeBSD
* | e0af16d Add message about OpenBSD
|/
* f0add60 (tag: 1.0.0) Add UNIX message
* 2f9f135 Add message to the world
* 5bd3a68 Add a file

The log shows that we diverged from our main branch with the e0af16d commit, then we merged back the changes from the release-1 branch into the main branch with the commit eed8406.

As the development continues you'll need to consider for how long you will continue to support the release-1 branch with possible bug fixes and other fixes.

You may also find bugs in the main branch which are also located in one or several of the release branches. In that case you can rarely merge bug fixes from the main branch to the release branches because the main branch will have changed too much. You can then either create a patch manually using the git diff command and then manually construct a patch, or you can simple edit the code manually by hand (often this is the solution with the least amount of pain). In either case, it's important to have a daily merge to main in order to keep the possible merging conflicts to a minimum. If you accumulate merges it becomes much more difficult to do a full merge.

That's it for now! This was an extremely simplified illustration of the Git workflow I prefer. I hope you have found it useful.

Last, but not least, please keep in mind that with Git workflows there is no one size that fits all! Don't be religous about it, find the workflow that you and your team likes best.