Explanation of Enhanced Git Flow

Accidentally messing up with Git is surprisingly common. However, there’s no one-size-fits-all answer to the question of the best way to use it.

This is because Git only provides the building blocks for branching, leaving the specific methods—known as branching models—up to developers. These models aim to bring order to the potential chaos of software development as codebases evolve.

You, like many other developers, probably just wanted something straightforward so you could focus on coding. So you opted for Git flow, a popular branching model. Initially, its logic might have seemed sound, but then you encountered difficulties—managing multiple release branches, figuring out staging options, and so forth. Or maybe Git flow just didn’t feel quite right for your needs. After all, every project is different, and no single branching model is perfect for all situations.

Here’s the good news: Enhanced Git flow, a variation on the classic Git flow model, simplifies common Git flow actions while retaining its core benefits.

The Ups and Downs of Classic Git Flow

I’ve been a big fan of Git flow since discovering how well it handles products released in major updates (in other words, releases).

These major updates demand significant time, similar to the two-week or longer sprints typical of Scrum. If a team has already launched a product, merging the next release’s scope into the production code (e.g., the main branch) can cause complications.

During initial development—before production and users—keeping everything in the main branch is perfectly acceptable. In fact, it’s ideal: It maximizes development speed without unnecessary complexity. However, things change in production, where stability becomes crucial for real users.

Imagine a critical production bug requiring immediate fixing. Rolling back all the main branch’s work just to deploy the fix would be disastrous. And deploying untested code—whether half-baked or seemingly complete—is clearly not an option.

This is where branching models, including Git flow, come in. Any robust model should address how to separate the next release from the live version, how to update the live version with the next release, and how to implement urgent fixes (hotfixes) for the current version.

Git flow tackles these scenarios by separating “main” (production/current version) and “develop” (development/next release) branches and providing rules for using feature/release/hotfix branches. This effectively resolves many headaches in release-based development workflows.

Yet, even in projects suited to classic Git flow, I’ve encountered its common drawbacks:

  • Git flow is intricate, with two permanent branches, three temporary branch types, and strict branch interaction rules. This complexity increases the likelihood of errors and the effort to fix them.
  • Git flow release and hotfix branches require “double merging”—first into main, then into develop. It’s easy to forget one. While scripts or VCS GUI plugins can simplify branching, they need to be set up on every developer’s machine.
  • CI/CD workflows often result in two final release builds—one from the release branch’s latest commit and another from the main merge commit. Ideally, the main one should be used, but their near-identical nature can cause confusion.

Introducing “Enhanced Git Flow”

I first used enhanced Git flow on a new, closed-source project. Working with one other developer, we initially committed directly to the main branch.

Note: Until a product’s first public release, committing directly to the main branch makes perfect sense—even for Git flow proponents—due to the speed and simplicity it offers. Without a production environment, there’s no risk of urgent production bug fixes. The branching overhead of classic Git flow is unnecessary at this stage.

As we neared the initial release, we decided we needed a more robust approach than committing directly to main. Our rapid progress and business priorities hadn’t allowed for a rock-solid development process with enough automated testing to ensure a constantly release-ready main branch.

Classic Git flow seemed appropriate. Separate main and develop branches and sufficient time between major updates meant that mostly manual QA would suffice. When I proposed Git flow, my colleague suggested a similar approach with some key differences.

Initially, I resisted. Some proposed modifications to classic Git flow felt too radical, potentially undermining the core concept and rendering the approach ineffective. Upon reflection, however, I realized that these adjustments actually enhanced Git flow without compromising its fundamental principles.

Following the modified approach’s success in that project, I used it in another closed-source project with a small team, where I was the primary developer and occasionally collaborated with outsourced developers. We launched that project after six months and have been using CI and E2E testing with monthly releases for over a year.

A typical Git commit graph when using enhanced Git flow. The graph shows a few commits on develop and main, and before their common commit, several date-based tags.

My experience with this new branching approach has been so positive that I wanted to share it with fellow developers to help them overcome the shortcomings of classic Git flow.

Similarities to Classic Git Flow: Development Isolation

Enhanced Git flow maintains the two long-lived branches, main and develop, for work isolation. (Hotfix and release functionalities are still available—note the emphasis on “functionalities,” as these are no longer branches. We’ll elaborate on this in the differences section.)

Neither classic nor enhanced Git flow enforces a strict naming convention for feature branches. Developers simply branch from and merge back into develop. Teams are free to use any naming scheme or trust developers to use descriptive names instead of generic ones like “my-branch.”

All features added to the develop branch up to a specific point will constitute the new release.

Squash Merges

I highly recommend using squash merges for feature branches to maintain a clear and linear history. Without them, commit graphs (from GUI tools or git log --graph) become messy even with a few feature branches:

Comparing the commit graph resulting from a squash merge strategy to that resulting from a merge commit strategy. The starting Git commit graph has its primary branch labeled "develop," with an earlier commit having "feature-D" and "feature-C" branching from it, and an even earlier commit having "feature-B" and "feature-A" branching from it. The merge commit result looks similar, but with each feature branch causing an new commit on develop at the point where it ties back to it. The squash merge result has develop as simply a straight line of commits, with no other branches.

Even if you can tolerate the visual clutter, there’s another reason for squashing. Without it, commit history views—including plain git log (without --graph) and platforms like GitHub—become difficult to follow, even with the simplest merge scenarios:

Comparing the commit history view resulting from a squash merge tactic to that resulting from a merge commit tactic. The original repo state is given in timeline form, showing the chronology of commits to two feature branches coming from a common commit "x" on develop, alternating commits between the branches, namely in the order 1a, 2a, 1b, and 2b. Merge commit results are shown in two variations. In the commit history resulting from merging the second branch, merging the first branch, then deleting both branches, the story is chronological, but not cohesive, and includes an extra merge commit for the first branch. In the history resulting from merging the branches in order before deleting them, the commits are ordered, "x, 2a, 1a, 2b, 1b," followed by the merge commit for the second branch, which is not even chronological. The commit history from squash merging simply has a single commit for each feature branch, with the story of each branch told by the committer.

The main drawback of squash merging is that the original feature branch history is lost. However, this is mitigated by platforms like GitHub, which exposes the full original history of a feature branch even after it’s deleted, preserving the information through the pull request used for the squash merge.

Differences from Classic Git Flow: Releases and Hotfixes

Let’s walk through the release cycle, as it’s (hopefully) your primary activity. When we’re ready to release the work accumulated in develop, it’s essentially a complete superset of main. This is where the significant differences between classic and enhanced Git flow emerge.

Git commit graphs as they change when performing a normal release under enhanced Git flow. The initial graph has main diverging from develop several commits behind the tip and by one commit. After tagging, main and vYYYY-MM-DD are even with each other. After deleting local main, creating it at the tip of develop, force pushing, deploying, testing, etc., main is shown even with develop, leaving vYYYY-MM-DD where main originally had been. After the deploy/test cycle, staging fixes on main (eventually squash merged into develop), and meanwhile, unrelated changes on develop, the final graph has develop and main diverging, each with several commits, from where they were even with each other in the previous graph.

Releases in Enhanced Git Flow

Each step of creating a release with enhanced Git flow deviates from the classic approach:

  1. Releases are based on main, not develop. Tag the current main branch tip with a meaningful identifier. I prefer tags based on the ISO 8601 date format prefixed with a “v”—e.g., v2020-09-09.
    • For multiple releases in a day (e.g., hotfixes), append a sequential number or letter.
    • Remember that these tags don’t necessarily reflect actual release dates. They serve as markers for Git to preserve the main branch’s state when a new release process initiated.
  2. Push the tag using git push origin <the new tag name>.
  3. Next, a slightly unconventional step: delete your local main branch. Don’t worry; we’ll restore it shortly.
    • All commits to main remain safe, protected from deletion by the tag we created. Every commit, including hotfixes (discussed later), is part of the development history.
    • Only one team member (the “release manager”) should perform this step for a given release. This role is typically assigned to the most experienced or senior team member, but it’s advisable to rotate it to share knowledge and enhance the infamous bus factor.
  4. Create a new local main branch at the tip of your develop branch.
  5. Force push this new structure using git push --force. The remote repo won’t accept such a significant change easily. This is safer than it appears because:
    • We’re merely moving the main branch pointer to a different commit.
    • Only one team member is making this change at a time.
    • Daily development occurs on develop, so moving main won’t disrupt anyone’s work.
      • (This contrasts with approaches like “Git flow without a develop branch,” where development happens directly on main.)
  6. Your new release is ready! Deploy it to staging for testing. (We’ll discuss convenient CI/CD patterns later.) Any fixes are committed directly to main, causing it to diverge from develop.
    • Simultaneously, you can start working on the next release in develop, mirroring classic Git flow’s advantage.
    • If a hotfix is needed for the production version (not the upcoming release in staging), refer to “Dealing with hotfixes during an active release…” below.
  7. Once the new release is deemed stable, deploy the final version to production and perform a single squash merge of main into develop to incorporate all fixes.

Hotfixes in Enhanced Git Flow

There are two hotfix scenarios. If a hotfix is needed when no active release is in progress—i.e., the team is working on a new release in develop—it’s straightforward: Commit to main, deploy and test the changes in staging, and then deploy to production.

Finally, cherry-pick the commit from main to develop, ensuring the next release includes the fix. For multiple hotfix commits, create and apply a patch (if your IDE or Git tool supports it) instead of multiple cherry-picks to save effort. Avoid squash merging main into develop after the initial release, as it’s likely to create conflicts with independent progress made in develop.

Handling hotfixes during an active release—when you’ve just force pushed main and are still preparing the new release—is enhanced Git flow’s least elegant aspect. Depending on the release cycle length and the issue’s severity, aim to include the fix in the new release itself. This is the simplest approach and won’t disrupt the overall workflow.

If that’s not feasible—the fix is urgent, and you can’t wait for the next release—prepare for a somewhat involved Git procedure:

  1. Create a branch (let’s call it “new-release,” but any naming convention works) at the current main tip. Push new-release.
  2. Delete and recreate the local main branch at the commit of the tag you created earlier for the current active release. Force push main.
  3. Introduce the necessary fixes to main, deploy to staging, and test. Once ready, deploy to production.
  4. Propagate changes from main to new-release via cherry-picking or a patch.
  5. Repeat the release procedure: Tag the current main tip and push the tag, delete and recreate local main at the new-release branch tip, and force push main.
    1. You can likely remove the previous tag as it’s no longer needed.
    2. The new-release branch is redundant and can be removed as well.
  6. You should be back on track with the new release. Finish by propagating the emergency hotfixes from main to develop using cherry-picking or a patch.
Git commit graphs as they change when performing a hotfix during an active release under enhanced Git flow. The starting graph has develop as the longest line of commits, with main diverging two commits earlier by one commit, and three commits before that, the a branch diverges by one commit, tagged v2020-09-18. After the first two steps above, the graph then has new-release where main used to be, and main is not even with v2020-09-18. The rest of the steps are then performed, resulting in the final graph, where main is a commit ahead of where new-release had been, and v2020-09-20 is a commit ahead of where v2020-09-18 had been.

With proper planning, high code quality, and a healthy development and QA culture, this method should rarely be necessary. It’s wise to have this contingency plan for enhanced Git flow, but I’ve never had to use it personally.

CI/CD Setup with Enhanced Git Flow

Not all projects require a dedicated development environment. Setting up a sophisticated local environment for each developer might suffice.

However, a dedicated development environment can foster a healthier development culture. Running tests, measuring coverage, and calculating complexity metrics on the develop branch can reduce the cost of mistakes by catching them early.

I’ve found these CI/CD patterns particularly useful with enhanced Git flow:

  • For a development environment, set up CI to build, test, and deploy to it on every commit to develop. Include E2E testing if applicable and beneficial.
  • Set up CI to build, test, and deploy to staging on every commit to main. E2E testing is also valuable here.
    • While seemingly redundant, remember that hotfixes won’t happen in develop. Triggering E2E on commits to main tests both hotfixes and regular changes before release, while triggering on commits to develop catches bugs earlier.
  • Configure CI to allow manual deployment of builds from main to production upon request.
The recommended setup of CI/CD with enhanced Git flow when there's a development environment ("dev") in addition to staging ("stage") and production ("prod"). All commits on the develop branch result in a build deploying to dev. Likewise, all commits to main result in a build deploying to stage. A manual request of a specific commit from main results in a build deploying to production.

These relatively simple patterns provide a powerful mechanism for supporting daily development operations.

The Enhanced Git Flow Model: Improvements and Potential Limitations

Enhanced Git flow isn’t a universal solution. It leverages the controversial practice of force pushing the main branch, which purists might disapprove of. Practically, however, it poses no problems when done correctly.

As mentioned, hotfixes are trickier during a release but manageable. With proper QA, test coverage, and other best practices, this should be infrequent. In my view, it’s a worthwhile trade-off for enhanced Git flow’s overall benefits over the classic approach. I’m curious to see how it performs in larger teams and on more complex projects where hotfixes might be more common.

My positive experience with enhanced Git flow primarily involves closed-source commercial projects. It could be challenging for open-source projects where pull requests are often based on older release branches. While there are no technical barriers to overcome, it might demand more effort. I’d appreciate feedback from experienced open-source developers regarding its suitability in such contexts.

Special thanks to Toptal colleague Antoine Pham for his key contribution to the idea behind enhanced Git flow.


Microsoft Gold Partner badge.

As a Microsoft Gold Partner, Toptal provides you access to a network of elite Microsoft experts. Build high-performing teams with the specialists you need - whenever and wherever you need them!

Licensed under CC BY-NC-SA 4.0