Okay, so based on Philip's answer, I did a little bit of research into grafts, replace, and filter-branch.
Because what I want to achieve is to rewrite history, pretending that I laid my repository out correctly in the first place, I decided that the way to go about this would be to graft the head of each branch to the tip of the previous branch, then run git filter-branch to make the change permanent.
Under the new workflow, when a change is completed and ready to go into release, it will be merged into the release branch. If you inspect the history, I want it to look like those merges took place and were resolved in favour of whatever the original branch head was.
If you look too carefully at the result, you'll notice that there's actually no merge commit -- each grafted "merge" leads directly into then next commit. This is not really as big an issue to me as having dozens of spare branches floating around, so I'm going to leave it unresolved.
Grafting

I created the file .git/info/grafts
. For this example there would be two entries in the grafts file - one for each new branch parent.
The first mistake I made was forgetting to include the original parent of the commit as well -- half my attachments to master disappeared, and it took me about half of the process to notice.
Second time around I got the contents of the graft file right
[commit] [parent] [parent]
hash2 p2 hash1
hash4 p3 hash3
Cleaning
I wound up with some untracked files which had been deleted in earlier commits floating around in my working directory. I'm not sure about the mechanics of how they wound up there, but I know that they're preserved in my commit history, so I just deleted the working dir copies and ran git reset --hard
for good measure.
branch-C is already pointing at the tip of the new release branch, so I'm just going to rename it:
% git branch -m branch-C release
The next step in the grafting process will be to make the grafts permanent. This will require using git filter-branch
to completely rewrite the commit tree
Rewriting history is okay for me right now, because I'm the only person who actually uses this repository. Funny story, the main reason I'm cleaning it up is because I'm planning to share the repo with others soon, and I don't want a headache on my hands trying to fix the layout after other people are relying on a stable history.
Because I'll be recreating all those commits, I don't want the old branch pointers to hang around, keeping my old, dead commit trees visible.
% git branch -d branch-A
Deleted branch branch-A (was hash1).
% git branch -d branch-B
Deleted branch branch-B (was hash3).
Due to the grafts, there are no hanging tips for git to complain about, so the deletes go smoothly.
I also sync the repository off-site, so my tracking branches are going to hold on to those dead trees as well. This is going to be resolved later on with a push --force
, but for now, I'm just going to drop my remote so that it doesn't confuse me when I'm double checking my changes locally.
% git remote remove origin
Rewriting history
Okay, it's time to do the unthinkable. My git repository, locally, has the correct branch layout, and there are no pointers to any commits other than the tips of the release and master branches.
I've checked out the release
branch.
With no arguments, git filter-branch
will walk down the tree and rewrite history to make all of my grafts permanent.
% git filter-branch
Rewrite (...) (73/73)
Ref 'refs/heads/release' was rewritten
As yet, I haven't created any of those shiny tags that I wanted in my original plan - that's because the tag pointers would have been pointing at defunct commit hashes.
Now that I've inspected the results and my repository looks the way I want it to, I'm going to clean up the grafts file and add those tags.
.git/info/grafts
is now grafting commits that I don't care about, so, in confidence, I can either remove the file altogether, or clear out the offending entries.
% rm .git/info/grafts
The commit tree still looks right, so I've gone through and added all the tags I want to various releases.
There's one last step to rewrite history -- that remote repository I like to sync to.
I'm re-adding my remote, which will make my commit tree look all tangled and sick for a while.
% git remote add origin (...)
At first I tried just doing a regular git push --force --all
, but that didn't actually delete the defunct branches. There's an option for that, it's called --prune
.
% git push --force --all
Counting objects: 161, done.
Delta compression using up to 2 threads.
Compressing objects: 100% (61/61), done.
Writing objects: 100% (86/86), 9.06 KiB, done.
Total 86 (delta 48), reused 0 (delta 0)
To (...)
* [new branch] release -> release
% git push --force --all --prune
To (....)
- [deleted] branch-A
- [deleted] branch-B
- [deleted] branch-C
Everything's perfect, so I've also deleted the backup info that git filter-branch made for the release branch:
% rm .git/refs/original/refs/heads/release