No Git branching model will let you cut through insufficient project management. You can't solve social problems through technical means.
A secondary problem is that Git's data model does not fit your way of thinking about development. In Git, each commit is a snapshot of the project state, along with the history that led to that state. A commit is not a diff – a diff can only be made between different states/snapshots. In particular, consider the following history where we have a main branch from which a feature A is branched. In turn, feature B is branched from feature A:
main x---x---M
\
feature A x---x---A
\
feature B x---B
When you want to merge A into main, and B into main, the diff will contain all the changes. In particular, the diff of feature B will include feature A since A wasn't merged yet. This typically makes review difficult. What typically happens is that A is merged first, and then B can be rebased onto the new state of the main branch:
main x---x---x-----------M'
\ / \
feature A x---x---x \
\
feature B x---B'
What can you do to avoid this problem?
- reduce cycle time
- make your pipeline wider
- make rebasing easier
The core issue here is that you have multiple pull requests and changes that depend on each other. There are different strategies to deal with this, and most apply on an organizational or project management level, not on a technical level. In practice, you will likely want to use a combination of these strategies.
Reducing your cycle time. Having pending pull requests is not a problem. But it is a problem if this impedes further work. You might therefore want to look for ways to reduce the time needed to merge a PR. That your team is small and low capacity does limit how quickly you can merge, but not as much as you might think.
In order to make it easy to merge PRs quickly, consider that the scarce resource is time and attention from other people on the team. You therefore want to look for ways that minimize utilization of this resource.
Have design discussions before implementing the code. Some code reviews are unnecessarily stretched out by debating how something was implemented. This is wasteful, so reaching agreement about the general architecture before writing the code can speed things up. If the appropriate design is unknown, consider a throw-away prototype or spike to evaluate a potential design.
Make PRs reasonably small so that they are easy to review. Use comments and commit messages to guide the reviewers.
Use automated review tools that minimize the need for human reviews. Linters, auto-formatting, automated test suites, and code coverage checks can help you detect common problems before asking someone else to review the code.
Does this even have to be reviewed? If the team members can trust each other to make good decisions, stringent review is unnecessary. The pull request workflow is most useful in open source projects where contributions might not come from a trusted core team. Within a company or trusted team, the value of PRs is more about knowledge-sharing.
It might be acceptable to let developers merge simple changes without review, especially if this is an easily reversible decision – not a “one-way door” decision that commits you to a particular design. For example, internal data models can be refactored at any time, but public APIs represent commitments that cannot be taken back. Let developers explicitly request reviews where they need it.
Make your pipeline wider. One of your problems is that you have many features that depend on one another. Stalls in feature A will impede progress on features B, C, D, E, and so on. Try to break such dependencies as far as possible.
Sometimes, it is possible to start by defining a shared data model which then allows features to be implemented separately. At the end, the features are integrated with each other. This does introduce some risk (the data model might turn out to be inadequate, the integration might reveal unexpected problems). But sometimes, these risks are more than outweighed by being able to develop and merge multiple features independently. Yes, this requires some upfront design discussion in the team.
Quite often, a software does not have a single feature set, but multiple areas where development is needed. This should be reflected when planning what work to do next. Sometimes, it might turn out to be faster to work on multiple unrelated features in parallel, so that you have something to do while waiting for reviews for the other features. But this will always be less efficient than concentrating on one area at a time, so that reducing cycle time is probably more valuable.
The Kanban project management methodology has a concept of “work in progress limits”. It doesn't make sense to add more and more pending tasks for the “waiting on review” state. Kanban can help see bottlenecks in your process. Eliminating bottlenecks improves cycle time. But a Kanban-managed project might have tasks across separate “swimlanes” that don't interact with each other. If a WIP limit is reached, it is possible to continue making progress on tasks in other states and tasks from other swimlanes.
Making rebasing easier helps to deal with frequent merges and rebases. To a large part, merge conflicts can be avoided simply by coordinating up front who is working on what, and coordinating changes more closely if it is clear that multiple PRs will touch the same code.
But there are also some ways to use tools to speed up dealing with frequent merging and rebasing. Previously, I said that a commit is a snapshot, not a diff. But when rebasing, Git creates new commits by replaying the changes produced by a commit on top of a new snapshot. By using the git rerere
feature (“reuse recorded resolution”), Git can remember how you resolve conflicts. This can be extremely helpful when rebasing the same code multiple times. So when you have a sequence of dependent features A-B-C-D, then:
- when A is merged, rebase B on the main branch, rebase C on B, rebase D on C.
- when B is merged, rebase C on the main branch (can reuse conflict resolution), rebase D on C (can reuse conflict resolution
- when C is merged, rebase D on the main branch (can reuse conflict resolution)
Ultimately, rebasing multiple dependent branches will still take O(n²) effort in total so minimizing such dependencies is important (whether by reducing cycle time or by widening your WIP pipeline). But git-rerere can make this effort more bearable. In principle, you might have tooling to automatically attempt a rebase of dependent branches when a PR is merged.