Sometimes when we reach for the stars, we forget to keep our feet on the ground. Whenever I sit down to write these articles, I wonder what I should write about. It's quite tempting to go for the latest nice buzzwords, like artificial intelligence, and I've written quite a lot of articles on those topics. Don't get me wrong, those articles are very valuable and those technologies are very exciting.
But as I sat down to write this article, I thought I should write about something that almost every developer needs to know. The reality is that we're still going to write code by hand, at least for the foreseeable future. Also, the most commonly used version control system on the planet right now is Git.
So I thought it made sense to spend some time talking about standard branching strategies for collaborative development using Git.
In the intricate dance of collaborative software development, Git stands as the conductor, orchestrating the flow of changes and ensuring harmony among team members. At the heart of this collaborative dance lies the power of Git's branching capabilities, allowing for parallel development, feature isolation, and efficient bug fixing. However, without a well-defined strategy, branching can quickly descend into chaos, leading to integration nightmares and a tangled web of commits. This article delves deep into various Git branching strategies, providing hands-on examples to guide their implementation and empowering your collaborative projects.
Fundamentals
Before I explore specific strategies, let's solidify your understanding of Git branches. In essence, a branch is a movable pointer to a specific commit in the repository's history. When you create a new branch, you're creating a new line of development that diverges from the main line. This allows you to make changes without directly affecting the stable codebase.
Let's understand how this works. I assume you have Git installed and have basic working knowledge of Git.
Go ahead and create a new directory somewhere on your machine and initialize it to be a Git repository.
md test && cd testS
echo "some content" > afile.txt
git init
git add . && git commit -m "First commit"
With your repository set up, now let's understand how branching works.
Before I go much further, a common challenge I see when understanding Git is visualizing what's going on. I could hand-draw all diagrams for this article, but you can just use VSCode with the GitLens extension to see what's going on, so that's what I'll do. At this point, GitLens should show you your commit graph, as can be seen in Figure 1.

You can create a branch using the following command:
git checkout -b new-feature
The above command both creates a new branch and switches to it. You could do the above in two commands if you wished, as follows:
git branch new-feature
git checkout new-feature
At this point, you should see in GitLens that you've switched to the “new-feature” branch, as can be seen in Figure 2.

Now you can list all branches in your Git repo as follows:
git branch
Although this repository is local to your machine, it could be backed by remote repository, say, on GitHub. You could list all branches, including remote branches, using the following command:
git branch -a
The Gitflow Workflow
Now that you have a basic repository working, let's understand a time-tested—and one of the original—workflows, called the Gitflow workflow.
The Gitflow workflow, conceived by Vincent Driessen, is a robust and widely adopted strategy designed for managing larger projects with scheduled releases. It defines certain very specific roles for different branches, and this is sort of a convention that a lot of projects use.
The main
(or master) branch represents the production-ready
state. Only tagged release versions reside here.
The develop
branch is the integration branch for all feature development. Developers merge their completed feature branches into develop.
The feature/<feature-name>
branches are short-lived branches branched off from develop and used to develop specific features. So, if I'm going to add a new feature, I'd create a branch called feature/<feature-name>
. It's quite common to create a GitHub issue and name your feature per the issue, also. This works very nicely because, as that feature merges, that issue can close; it just keeps everything nicely tied together.
The release/<version>
branch is created from develop when a release is imminent. This branch is used for final bug fixes and preparation and it's merged into both main and develop. When you merge it into main, you tag it with the version. The idea is that if you have four-week sprints, every four weeks, you'd create a release/<version>
branch.
But what if you have an urgent production problem that can't wait four weeks? For that, the hotfix/<issue-name>
branch is created from main to address urgent production bugs that can't wait for a full release cycle. This is also merged into both main and develop.
A Real-World Example
Let's see how this works in practice. Let's say you're asked to build a new feature for user authentication. You won't actually write authentication code but let's focus on the Git aspects here.
To start, let's clean the new repo you were working with so there is only one branch “main” left.
git checkout main && git branch -D new-feature
The capital D flag passed to Git branch deletes the feature branch you had. How will you check what branches are on your machine now? I'll leave that as a quiz for you because I have a feature to implement here.
Developing the Feature
The developer is a part of a team, and the developer is in the middle of a sprint. The team already uses the Gitflow workflow, so there is already a develop branch in existence. You don't have such a branch in your local repo, so go ahead and create one, as shown below:
git checkout -b develop && git checkout main
The developer is given the task of building this feature. The developer begins working on the user authentication feature by creating a new feature branch.
git checkout develop && git pull
git checkout -b feature/user-authentication
Although the “Git pull” part isn't 100% essential, it's a good idea to do it. This is because there may be other developers in the team who've pushed changes to the remote repo, so you want to start working on the latest of the codebases as possible to avoid any merge conflicts later. Because my repo is 100% local, technically, I'm not collaborating with anyone here, and my Git pull command will return an error, something along the lines of “there is no remote upstream,” etc. Let's not worry about the “pull” part if you're following along. Just do a Git checkout develop.
Nicely done! Now let's make some code changes to add the user authentication feature.
echo "newcode" > userauth.txt
git add .
git commit -m "Implement user auth"
What you just did here is you added the functionality for user authentication, you staged the file, and you committed it to your feature branch. This can be seen in Figure 3.

Merging Your Work
At this point, the feature is developed but it's in your branch. The next thing you need to do is merge your work into the develop branch. Here's how you do that.
git checkout develop
git merge --no-ff feature/user-authentication
git branch -d feature/user-authentication
git push origin develop
What you just did here is you first switched to the develop branch. Then you asked to merge the feature/user-authentication
branch into develop.
The --no-ff
flag ensures that a merge commit is created, preserving the history of the feature branch.
Then you deleted your feature branch and pushed your changes to remote. Again, because you haven't set up a remote in this article, you'll get an error there, but I'm going to keep mentioning remote for the sake of completeness.
Take a quick glance at what the commit graph looks like in the feature graph now. This can be seen in Figure 4.

Isn't that nice? It clearly tells you that you branched, implemented user auth, and then committed it to develop.
Preparing a Release
Fast-forward four weeks. You and your team have been developing a lot of features. It's now the end of the sprint and a bunch of features have been implemented and merged to develop. It's time to prepare a release.
When develop reaches a stable point for a release (e.g., version 1.0), a release branch is created.
git checkout develop
git checkout -b release/1.0
Now you can make any minor final bug fixes, etc., and commit them, as needed, to the release branch. Once the release is ready, you then merge into main and tag it with the version number. The changes made in the release branch, if any, are also merged back into develop.
Here's how you merge into main and tag it:
git checkout main
git merge --no-ff release/1.0
git tag -a v1.0 -m "Release version 1.0"
And then you merge the latest into develop as follows:
git checkout develop
git merge --no-ff release/1.0
And then, finally, you can get rid of your release branch.
git branch -d release/1.0
If you set up a remote, you can push the changes upstream as follows:
git push origin main --tags
git push origin develop
Congratulations on doing the release. You're now set up for your next sprint.
Releasing a Hotfix
Imagine that you were in the middle of a sprint and a critical bug is discovered in production. How would you go about fixing that?
The issue here is that you cannot do this hotfix release from the develop branch because it may have work in it that isn't ready to be released yet. To address the hotfix, you'll need to do a branch from the production version that's the main branch.
Here's how you'd do that:
git checkout main
git checkout -b hotfix
In this new hotfix branch, you can perform your changes and commit them.
# Implement the hotfix
git add .
git commit -m "made my changes"
Now that your hotfix changes are done, you need to merge them into main and do a release from main so this hotfix lands in production as soon as possible.
git checkout main
git merge --no-ff hotfix
git tag -a v1.0.1 -m "Hotfix release"
You also have to make sure that the hotfix changes are accommodated for the next sprint. Make sure to merge your hotfix in the develop branch as well, so it gets included in the next sprint.
git checkout develop
git merge --no-ff hotfix
That's it. Now go ahead and delete your hotfix branch and, if applicable, push your changes to remote.
git branch -d hotfix
git push origin main --tags
git push origin develop
Now you can continue working on the develop branch as usual.
The full Gitflow workflow can be seen in Figure 5.

If Figure 5 is a bit of an eyechart, you can find its equivalent mermaid in Listing 1, which you can render live at https://mermaid.live
or in any markdown editor that supports mermaid diagrams.
Listing 1: Gitflow workflow mermaid
graph LR
subgraph Central Repository
main -- Release --> v1.0 -- Hotfix --> v1.0.1
main -- Next Release --> release/1.1
develop -- Feature A --> feature/a
develop -- Feature B --> feature/b
develop -- Feature C --> feature/c
develop -- Release Prep --> release/1.1
release/1.1 -- Merge to main --> main
release/1.1 -- Merge to develop --> develop
hotfix/fix-security -- Merge to main --> main
hotfix/fix-security -- Merge to develop --> develop
end
subgraph Developer 1
feature/a -- Develops --> LocalFeatureA
LocalFeatureA -- Merges to --> origin/feature/a
origin/feature/a -- Pull Request --> develop
end
subgraph Developer 2
feature/b -- Develops --> LocalFeatureB
LocalFeatureB -- Merges to --> origin/feature/b
origin/feature/b -- Pull Request --> develop
end
subgraph Developer 3
feature/c -- Develops --> LocalFeatureC
LocalFeatureC -- Merges to --> origin/feature/c
origin/feature/c -- Pull Request --> develop
end
subgraph Release Manager
release/1.1 -- Prepares --> LocalRelease1.1
LocalRelease1.1 -- Merges to --> origin/release/1.1
origin/release/1.1 -- Merges to --> main & develop
end
subgraph Hotfixer
main -- Creates Hotfix --> hotfix/fix-security
hotfix/fix-security -- Fixes Issue --> LocalHotfix
LocalHotfix -- Merges to --> origin/hotfix/fix-security
origin/hotfix/fix-security -- Merges to --> main & develop
end
style main fill:#,stroke:#333,stroke-width:2px
style develop fill:#333,stroke:#333,stroke-width:2px
style release/1.1 fill:#333,stroke:#333,stroke-width:1.5px
style feature/a fill:#222,stroke:#333,stroke-width:1px
style feature/b fill:#222,stroke:#333,stroke-width:1px
style feature/c fill:#222,stroke:#333,stroke-width:1px
style hotfix/fix-security fill:#333,stroke:#333,stroke-width:1.5px
style v1.0 fill:#333,stroke:#333,stroke-width:2px
style v1.0.1 fill:#333,stroke:#333,stroke-width:2px
Pros and Cons of Gitflow Workflow
The Gitflow workflow gives you a clear separation of development, release preparation, and hotfixes. It aligns well with sprints and has a well-defined process for managing different stages of the software lifecycle. Also, it helps parallelize the development of multiple features.
On the downside, it can be a bit of an overkill for smaller teams or projects with rapid iteration. Also, sometimes it feels a bit inflexible and requires strict adherence to the workflow.
The Feature Branch Workflow
You just understood the Gitflow workflow that allowed you to parallelize development by creating a develop branch. The agile feature branch workflow, on the other hand, is a simpler and more agile approach where each new feature or bug fix is developed in its own isolated branch, branched directly off the main branch.
Let's see how this works in practice. Imagine that you're a developer in a large team, and you just got an issue assigned to you. Before you begin work, you create a branch from the main branch:
git checkout main
git checkout -b myfeature
In this main branch, you perform your work and commit. For brevity, I'll omit those commands.
When you're done with your work, you merge your work into the main branch:
git checkout main
git merge --no-ff myfeature
At this point, you can delete your feature branch and push your changes to remote:
git branch -d myfeature
git push origin main
What about release? This is the beauty of the feature branch workflow. Releases can happen all the time and at any time. What keeps you safe here are unit tests and integration tests. In the real world, you'd typically have CircleCI checks that run and ensure that unit tests and integration were written and that they pass. Additionally, you can have similar hooks that make sure that before check-in, all your style checks and any other validations pass before you're allowed to merge the changes into the main branch.
The whole idea behind this workflow Is that you've put in an investment ahead of time in the shape of tests and CI checks to facilitate any developer getting their code to production as soon as possible.
Pros and Cons of Feature Branch Workflow
The feature branch workflow at the get-go feels a lot simpler and easier to understand than Gitflow. It also provides good isolation for individual features. And finally, it keeps the main branch relatively clean. You'll also note that hotfixes are now no different than a feature branch.
On the negative side, though, the feature branch workflow can lead to more frequent merge conflicts if many developers are merging into main simultaneously. This can be especially problematic in larger projects. And because it feels less formal compared to Gitflow, you need additional knowledge to know when releases occur so your code makes it to production.
Perhaps the biggest negative of the feature branch workflow is the upfront investment you have to do in CI checks and tests to ensure that your pace of changes doesn't affect a stable production environment.
From my experience, as projects get larger, this investment is necessary anyway, no matter what workflow you use. So I don't particularly see that as a negative.
The feature branch workflow can be seen in Figure 6.

You can see the equivalent mermaid for Figure 6 in Listing 2.
Listing 2: Feature branch workflow mermaid
graph LR
A[Main Branch] --> B(Create Feature Branch);
B --> C{Develop Feature};
C --> D(Commit Changes);
D --> C;
C --> E{Ready for Review?};
E -- Yes --> F(Create Pull Request);
F --> G(Code Review);
G -- Approved --> H(Merge to Main Branch);
G -- Changes Requested --> C;
H --> I(Deploy);
E -- No --> C;
The Streamlined Trunk-Based Development
I like to think of trunk-based development as an even more agile version of the feature branch workflow. It's a strategy favored by teams practicing continuous integration and continuous delivery (CI/CD). It emphasizes working on short-lived feature branches that are frequently merged back into a single, central trunk (typically main).
In practice, it's identical to the feature branch workflow, except you merge early and you merge often. You don't wait for a full feature to finish and do a humongous merge; you merge it at logical points, even if the full feature isn't ready.
Besides the frequency of merges, the commands are identical to the feature branch workflow.
The streamlined trunk workflow can be seen in Figure 7.

You can see the equivalent mermaid for Figure 7 in Listing 3.
Listing 3: Streamlined trunk based workflow mermaid
graph LR
A[Developer Workspace] -- Feature Development
--> B(Commit to Trunk);
B --> C{Build & Test};
C -->|Successful| D{Deploy to Staging/Production};
C -->|Failed| E[Fix Immediately on Trunk];
E --> B;
D --> F[Monitor & Iterate];
style B fill:#222222,stroke:#333,stroke-width:2px
style C fill:#222222,stroke:#333,stroke-width:2px
style D fill:#222222,stroke:#333,stroke-width:2px
style E fill:#222222,stroke:#333,stroke-width:2px
Pros and Cons of Streamlined Trunk-Based Workflow
The biggest advantage of the streamlined trunk-based workflow is that your frequent merges require you to merge less frequently, and therefore have fewer merge conflicts. Merge conflicts can get complicated to resolve, especially when you have to manually resolve them. Think of Node.js projects where you have package-lock.json
files automatically generated and node packages have interdependent version hells to deal with. How would you even go about manually merging that? Every language has such idiosyncrasies.
The other advantage is that you get faster feedback loops and quicker identification of issues. Let's say your teammate and you are working on a feature that you think are independent of each other. Your teammate makes a modification to an underlying library that affects your code. You'll know early in your development lifecycle that your teammate's changes have broken your approach. This means you can adjust early and avoid surprises or bugs in production.
Another advantage of this workflow is that it causes less branch pollution. It's quite common to see Git repos with lots of branches that go stale and remain unused. With this workflow, you can be confident that no branch should last more than a few days. In fact, you could set up an automated job to mark the branch stale after a couple of days and even prune branches that are older than a month. In the long run, this will keep your Git repository clean.
On the negatives of the streamlined trunk-based workflow, it's obvious that this workflow will fail miserably unless you invest heavily in robust automated testing and CI/CD pipelines to ensure the stability of the main branch. Also, this workflow requires strong communication and collaboration among team members.
What's the Right Strategy for My Team?
The right strategy isn't a one size-fits-all solution. There are many factors to consider when picking the right strategy for your team.
Consider your team's size and structure. Larger, more distributed teams might benefit from the structured approach of Gitflow, while smaller, co-located teams might thrive with the simplicity of feature branching or the agility of trunk-based development.
Project complexity also plays a big part. Long-lived, complex projects with scheduled releases might favor Gitflow. Projects with rapid iterations and frequent deployments might lean toward trunk-based development.
Another factor to consider is release cadence. Teams with infrequent, major releases might find Gitflow suitable. Teams with continuous delivery pipelines will likely prefer trunk-based development.
Then there's your team's familiarity with Git. Simpler workflows like feature branching are easier to adopt for teams new to Git.
Finally, think about your organization's risk tolerance: Trunk-based development requires a high degree of confidence in automated testing to mitigate the risk of introducing bugs directly into the main codebase. I have seen many examples where management didn't want to invest in testing and CI/CD, which is a bad idea to begin with. But if you don't have solid CI/CD and testing in place, a trunk-based workflow is not for you.
A Few Best Practices
Before I wrap up, let me talk about a few best practices that apply to all of these strategies.
Keep Branches Short-Lived
The longer a branch exists, the more it diverges from the main codebase, increasing the likelihood and complexity of merge conflicts. Merges can be very complicated and the longer your divergence is, the more error-prone they can be. I can tell you from experience that I've chosen to re-implement a feature rather than to bother merging it. You should aim to merge feature branches back into the integration branch frequently.
Use Descriptive Branch Names
It's always a good idea to use clear and concise names that indicate the purpose of the branch. For example, feature/add-user-roles
, bugfix/resolve-database-connection-error
, etc. This improves clarity and makes it easier to understand the repository's history. Additionally, when merging a branch, attach GitHub issues with the branch so those issues get closed automatically when your branch merges. The great thing about this approach is that you can correlate any change to why the change was done, and your project managers remain happy because they get a clear view of what features got implemented in a sprint.
Commit Frequently and with Meaningful Messages
When I'm working on a large feature, I like to think ahead and break down my work into logical, small commits with clear and descriptive commit messages. This makes it easier to understand the changes introduced by each commit and facilitates easier rollback if necessary.
It's perfectly okay if a commit doesn't reflect fully working code just yet. Think of commits as logical points of work that you don't want to lose.
But wait a minute. You might be thinking that committing code that's not working is a bad idea, right? No, not really. Logical breakpoints where you've implemented a piece of functionality for your own purposes is how you should think of commits. When you are ready to merge your code, at that point, you should have tests and be compiling code without errors. This is what pull requests are for.
Regularly Pull and Rebase/Merge
Let's say that I'm working on a feature that takes me a couple of days to develop. Also imagine that I work as a part of a large and distributed team. It's not uncommon that a lot of my teammates are pushing and merging their changes all the time.
To avoid any surprises, I want to make sure that I'm tracking the main branch as closely as possible. This is achieved by pull and rebase/merge.
In Git, Git pull
is a command that updates your local repository with the latest changes from a remote repository, typically the main branch. It combines two Git operations: Git fetch
(downloading changes) and Git merge
(integrating them) into a single command.
Also, a Git merge
is a Git command that combines changes from one branch into another. It integrates the changes from a specified branch into the currently checked-out branch.
A Git rebase
on the other hand, is a command that reworks a branch's history by moving or combining its commits to a new base commit. It essentially rewrites the history of a branch, making it appear as if it was branched off from a different commit. Instead of creating a merge commit like Git merge
, rebase applies changes one by one, resulting in a cleaner, linear commit history.
Embrace Pull Requests
At a point where you're ready to ship your code into production and you want to have it merged in main, you should employ pull requests.
Pull requests are an opportunity where your final unit tests, integration tests, and CI/CD checks can run.
But perhaps the biggest advantage of having a pull request is that it gives your teammates an opportunity to review your code. This allows team members to review and discuss changes before they're integrated into the main codebase, improving code quality and reducing the risk of introducing bugs.
Clean Up Merged Branches
This one is pretty obvious. Although it's very easy to create new branches in a repository, you'll end up with a mess of branches over a period of time. Once a branch has been successfully merged, delete it from both your local and remote repositories to keep the workspace clean and manageable.
A good way to do this is to have automated jobs run that mark branches as stale after a given amount of time. Once a branch has been stale for a larger duration of time, the branch can be automatically deleted. Marking a branch stale is a matter of attaching a label to the branch. Similarly, you could have a do not delete label to prevent automatic deletion of branches so developers don't accidentally lose work.
Summary
This article was a back-to-fundamentals article. I feel we are so focused on the leading bleeding edge that such fundamental concepts are rarely discussed. This leaves new developers to learn only from experience, which can be painful for everyone.
Mastering Git branching strategies is a continuous learning process that requires understanding different approaches and adapting them to your team's unique context. By carefully selecting a strategy that aligns with your team size, project complexity, and release cadence, and by consistently adhering to best practices, you can transform Git branching from a potential source of confusion into a powerful tool for seamless collaboration. Picking a well-orchestrated dance of Git branching ultimately leads to more efficient workflows, higher-quality code, and a smoother path to successful project delivery.
Did you find such a back-to-fundamentals article useful? Do let me know.
Until then, git coding!