We have to distinguish two kinds of versions:
- VCS like Git store the development history of the software.
- These versions have nothing to do with different editions or variants of the software.
Trying to conflate both is a recipe for pain.
Especially with Git, a commit represents a specific state of the software and not just a set of changes. So if you have a base branch and a branch for one software variant, merging between them always involves reconciling the current state of both variants. This is far more work than re-applying the same changes (which is possible in Git with
git cherry-pick, but that creates a completely unrelated new commit without the history of the original commit).
For Git, a fork is effectively the same as a branch. Creating a separate forked repository has no particular technical advantages.
If Git branches are unsuitable for maintaining multiple variants of the software, what are they for? Branches are effectively floating tags that point to some commit. That makes them suitable to denote “the most recent X”, where X might be “the latest stable version” (e.g. for the master branch), or “the current development version” (if you have a development branch). Additionally, branches are suitable for concurrent threads of development. E.g. work can proceed independently on multiple feature branches until they are merged together.
How can we maintain multiple variants/editions if we cannot use branches? The trick is that you should only have one variant of your source code, and instead generate the editions through a build process or through configuration.
For example, you might want to create an app with different branding. Then create multiple asset bundles for each branding. When you build your app, select only the asset bundle for the variant you are currently developing. An “asset bundle” might be as simple as two directories
customer-2/ that each contain a
If you want variants that behave differently, things do get a bit more complicated. We can now use feature toggles and/or dependency injection to configure the behaviour of the software. This may happen at build time or run time. These toggles might be conditionals or ifdefs, but using dependency injection with the Strategy Pattern can keep your code simple.
E.g. if you have a standard edition and a pro edition, and some operation is only supported in the pro edition, then the code that performs this operation will behave differently depending on the configuration: the pro edition will just perform the operation, whereas the standard edition might hide the UI for that operation, or raise an exception, or prompt the user to upgrade.
By maintaining a single code base for all your variants, your code does become a bit more complex because it exhibits more configuration points and more possible code paths.
However, you do get a lot more flexibility and agility for your development:
- You can make broad changes without having to tediously merge them into other variants.
- In particular, you only need to fix bugs once for all your editions.
- Common behaviour only has to be tested once.
- Because you have one code base, you will see incompatibilities between various features earlier – and can fix them, before it's too late.
The last time I worked on a “one branch per customer” repository, this lead to considerable difficulties. A lot of work that would benefit all branches (like improved build processes, tests, and bug fixes) effectively had to be reimplemented for each branch because they had subtly diverged. As merges became increasingly inconvenient, code was copied and pasted between branches. One branch saw an extremely helpful refactoring that impacted all modules. Upon merging other branches, the refactoring work had to be effectively repeated in order to account for subtle modifications. I'd estimate we wasted around 10% – 30% of our effort through this setup, which in retrospect was far more than the time needed to add feature toggles to our build process – which we eventually had to do anyway.