This is the first post in a series to expand on various utilities I wrote to assist my work with Git. Some of these utilities are located in a repository on Github called misc-gitology.
Today I'll introduce the history flipper -
The problem with splitting commits
When working with Git and browsing other developers' commit history, it is clear to many developers that separating logical changes to commits often provides value to reviewers. It also makes it easier to revert changes if needed, or when bisecting for bugs.
However it is often hard to abide by the rule. When we are about to make a change, sometimes we find ourselves refactoring or doing more changes along the way, and we end up doing many preceding and/or proceeding changes to support the change we wanted to do in the first place.
Managing several closely related changes together while they are still "hot in the stove" really depends on development style and organization of the person doing so. Some manage to take every change to its own branch and commit it right away, nice and cleanly. Others have the discipline to split right away to separate commits and rebase-squash them with fixups. However, others cannot afford many of the context switches that would be involved and prefer to delay "feature splitting" to the very end once everything feels more mature and well formed. The utility discussed in this post is more helpful to the latter group of developers.
Splitting a large commit that is currently in
HEAD involves doing some Git
maneuvering starting with
git reset, proceeding to
git add and
git add -p, and an occasional
git commit. However this has a limitation - some of
the logical changes may be dependent on one another, in this case
git add -p
would not help. For example, suppose two features add to a global list, where
they modify the same location in a file.
History flipping to the rescue
Another way to tackle the problem of untangling features from one another is this - if I can do the work to remove the features one by one, it would be the mirror image of the work of rewriting these features from scratch, and in that case I just need an elaborate Git trick to flip the history on itself!
In further detail, I have a large commit implementing A+B+C. If I write a new commit that reverts A, and another one that reverts B, and flip the history of the three versions, the result would be a history that implements all three versions separately.
So this is where
git-flip-history comes into play, where it also makes sure
that the commit log looks sane afterward.
For example, we have the following commit, with a tentative commit message:
219bd740a8f1 WIP - three changes packed into one
Our purpose is to reach a state where we have three commits each describing a separate feature (can also be a bugfix any other kind of change).
Here, we'll manually revert the first two features into two commits, and for
each one put specially crafted commit message with a
Revert: prefix. This
can be done in the editor, and
git checkout can also assist. Once we're done,
for the original commit, we'll reword it so it is prefixed with a
prefix, describing the remaining feature which we have not reverted. (i.e.
whatever change the diff to
HEAD~3 presented). We are free to write full
commit messages, as long as the
First: prefixes are there.
Following our work to revert the features and reword the commits accordingly, our history looks like this:
509c3964befd Revert: Ignore type aliases 8941699bfb69 Revert: Treat DefKind::Mod as being in the value namespace 372d10a7ffcc First: Have trait names on their own namespace
Now that we are ready, we run
git-flip-history. This requires no user input,
HEAD looks like this:
a086ec0322a4 Treat DefKind::Mod as being in the value namespace 9d78613ed775 Ignore type aliases 86d20b3e77dc Have trait names on their own namespace
Observing the changes with
git log -p, we should see a sane history now,
where each commit adds the feature that the commit message talks about.
It's worth to stress out that flipping the history like this does not require
us to solve any conflicts, unlike with
git rebase -i in the case where
changes overlap on file offsets. Untangling the changes from one another is
done only in the process of creating the
Revert: commits, and you are free
to use whatever method e.g.
git checkout -p, or manual editing, in order to
create these commits.
To explain how this is possible, let's look at it from another angle.
We have the following history:
HEAD Revert A This tree has C HEAD~1 Revert B This tree has A+C HEAD~2 Implement A+B+C This tree has A+B+C
By flipping, we now have this (start by comparing the third column):
HEAD Implement B This tree has A+B+C HEAD~1 Implement A This tree has A+C HEAD~2 Implement C This tree has C
How does it work
git-flip-history program is a bash script that looks back at the history,
Revert: prefixes and stops when seeing a commit with the
First: prefix. The history recreation process does not do any working tree changes,
by simply invoking the
git-commit-tree command. The resulting topmost version
is identical to the original branch version, and the branch is reseted using
git reset --hard to the new history. The script currently requires a clean
Git status for good measure (this may change in the future).