Two things I like to do in Git never get along.
Work on a development branch and then merge changes into master when they're ready to go to production.
Use interactive rebase to keep my repo's history clean.
The problem came when I would merge some code from dev into master, realize I had made a mistake, and then go back to dev to make the change.
I wanted to interactive rebase to combine my original (but not quite right) commit with the change I made to fix my mistake.
But when I did this and then tried to merge that combined commit back into master, it either created a merge conflict–if the same piece of code changed in different ways in each commit–or a merge commit.
Either way, the whole point is to clean up the history, not have to make another garbage commit.
I finally understood was going on after allowing it to happen way too many times.
Usually merging is smooth because I update my dev branch from master before starting new work. So each commit inherits from HEAD on master, and when I merge them back into that branch, it only needs to fast-forward.
But in this situation, after the fast-forward happens, and HEAD is the same for master and dev, I've gone back into my dev branch and gotten rid of that HEAD commit by interactive rebasing.
My dev branch now has no knowledge of the commit that's still HEAD on master, so the new interactively-rebased commit can't possibly inherit from it.
It stands to reason that when I try to merge this new commit into master, Git sees that the paths have diverged, and a merge commit of some kind will be necessary.
Now that I see what is causing the problem, the solution becomes easy.
After interactively rebasing on dev, I switch to master and do a hard reset, rolling back any commits I've made since the last merge.
Another way to explain it is that I'm resetting the master branch so that HEAD becomes the same commit that my rebased commit on dev inherits from. After all, any code that was in those commits is still present on dev, if I haven't changed it in subsequent commits.
Now when I merge from dev into master, the branch can fast-forward again. Of course, I also have to force-push when I want to send my code up to Github, plus if I've already pulled the code down somewhere else, I have to reset the branch there too, if I don't want a merge conflict.
Maybe a lot to worry about for cleaner history, but I think it's worth it. That's a debate for another day.
Anyway, this seems obvious to me now, but that's only because I've been making more of an effort to improve my mental model of how Git operates.
If you need to do that, I highly recommend reading the first few chapters of the Pro Git book. You may find mysterious Git behavior becoming slightly less mysterious.
Another great resource for solving common but oblique Git problems is Oh Shit Git.