I'm going to explain some Git background to understand why long-lived forks are so difficult.
In Git, a version/commit represents a snapshot/state of the source code. It does not represent a set of changes. Therefore, no elegant way exists to track different “editions” or “variants” of your software.
A branch and a fork/separate repository are more or less the same thing as far as Git is concerned. Within the history of a Git repository, neither of these concepts exist: there are only commits. In particular, a branch is just a floating label that points to some commit. So while branches are often used to organize different threads of development, they do not represent those changes. Instead, the HEAD commit of a branch just represents the current state of development, and the commit contains any history that led up to that state.
Merging tries to unify the state of two (or more) commits/source code snapshots. First, Git looks for the latest common ancestor of all parent commits, then calculates the diffs between each parent's head and the common ancestor. Then it tries to merge both diffs. If there is no conflict, Git applies the changes and creates a new commit with this state.
The more changes there are, the more merge conflicts you get and the more difficult merges get. Therefore, merging early is imperative to keep the workload manageable. But this also means you should merge two branches in both directions. Otherwise, unmerged changes keep accumulating on one branch and you will need to resolve the same conflicts again and again upon each merge:
/ / /
In the above example, there's a master branch and a long lived fork.
3 is a merge with merge-base
a. This merges the changes
a..2 and records a new commit
3. Relative to the fork, this merge represents how the changes
a..c were applied. Because merge conflicts may have been resolved, this may not actually be equal to the changes
5 is another merge, this time with the merge-base
c. This merges the changes
c..4. Note that the commit
3 (which represents how previous conflicts between the branches were resolved) is part of only one of these branches. Where the two branches differed during merge
3, they probably still differ during merge
5 so you will have to resolve those differences/conflicts again.
Effectively, each merge is carried over to the next merge and effort only increases. Unless you merge in both directions. But that runs against your goal of having
Fancy stuff only in the fork.
(There are alternative approaches like rebasing and cherry picking that apply a set of changes on top of a different commit. Because this only looks at the changes introduced by those commits and not at the source code snapshot they represent, this suffers less from conflicts. However, this doesn't merge Git histories but records entirely new, unrelated commits to represent the new source code state (history rewriting). And if the fork diverges too far, the diff may no longer “fit” the source code, requiring you to apply every change manually.)
So Git is unable to help here. There are two approaches:
One is to view one variant of your software as a set of patches on top of the other. The fork does not maintain the final result of those changes, but only the diffs that represent these changes. The original repo might then be a submodule. Whenever the original changes, you edit the patches so that they keep fitting. Within Git, these could also be a few commits that you continuously rebase on top of the original master, but that loses the history of your changes. This is only appropriate for very small, surgical changes that cannot be merged into the original repository. Maintaining patches would be sensible for small extensions or bugfixes that you cannot contribute back.
The better approach is to solve your problem on a software architecture level, not on a Git level. Instead of making direct changes to the software, make the software so configurable that the changes can be implemented as extensions. Relevant keywords: Open/Closed Principle, Feature Toggles, Dependency Injection, Design Patterns, Plugins. So either the main project exposes extension points so that you can insert the fancy changes, or you literally have a single code base where fancy/non-fancy mode can be selected at build time or at run time.