Code formatters play a crucial role in maintaining a consistent coding style across a project. When collaborating with a team, a code formatter ensures a uniform “look” for your code, irrespective of the individual author. This not only enhances readability but also results in smaller differentials in pull requests. To achieve this, all contributors must adhere to the same formatter and its configuration, stored within the project’s Git repository. Ideally, automated checks should be set up for every commit or pull request.

But what if you realize there’s no formatter set up for a project after it has already started? In this blog post, I’ll share my steps for addressing this issue, particularly for repositories where all branches are frequently rebased.

Step 0. Prioritize PR Merges

Given that applying a formatter will impact almost all files and potentially lead to conflicts, it’s advisable to merge pull requests before proceeding with the formatter implementation. This minimizes the workload in later steps.

Step 1. Introduce the Formatter Config

We begin by adding the files that house your formatters’ config the config (like .editorconfig, .clang-format, etc.). Place a script in the root your repository – say, format_all.sh – that applies the formatter to all files. Your script might look something like this:

#!/bin/sh
# Script to format all the files in this repo in place.
# Your script will likely use a different formatter.
# This one uses find to find all c++ related files and then executes
#   clang-format -i {file}
# on each of them.


find . -regex '.*\.\(cpp\|hpp\|cc\|cxx\|c\|h\)' -exec clang-format -i {} \;

Don’t execute the formatter just yet! Add and commit the script and the formatter config files. Next, tag that commit for future reference (we’ll need it in step 3).

# Add ONLY the formatting files
git add .editorconfig .clang-format format_all.sh
git commit -m "clean: introduce formatter configuration"
git tag tmp/fmt-add-config # for future reference

Step 2: Apply the Formatter and Commit

In the root of your repository, run the format_all.sh script and commit the results.

./format_all.sh
git add --all
git commit -m "clean: format all the files"
git tag tmp/fmt-applied

Step 3. Fix all the branches

For each branch that couldn’t be merged earlier, follow these steps.

git rebase tmp/fmt-add-config
git filter-branch -f --tree-filter ./format_all.sh -- tmp/fmt-add-config~..HEAD
git rebase --empty=drop tmp/fmt-applied
git push --force-with-lease

I’ll break down these steps below.

Step 3.1 Rebase on the commit adding the formatter.

git rebase tmp/fmt-add-config

This makes the code in this branch as close as possible to the code you applied the formatter on (step 2). Additionally, this branch now also contains the formatter config and format_all.sh script.

It may be possible that you have some rebase conflicts here, but these are not related to applying the formatter. If you hit one of these, fix the conflict, git add the conflicting file and git rebase --continue.

Step 3.2: Apply the formatter on all the commits of this branch

Now, we will format all the files, to do this, we will use the wonderful git filter-branch, a program that edits the contents of the files at each commit.

git filter-branch -f \
  --tree-filter ./format_all.sh \
  -- \
  tmp/fmt-add-config~..HEAD

Filter branch will check out every commit between the one where we added the formatter config and where we are now (HEAD). At each commit it executes the ./format_all.sh script, which replaces all the file in the commit with their formatted counterparts.

Our current “tree filtered” branch, and the formatted main branch now only differ at those places where there were actually changes made.

The attentive reader may have noticed that this branch does not have our “format all the things” commit. However, on this branch the commit that introduced the formatter config has been rewritten to also include the formatting. In the next step we will paste these two histories together.

Step 3.3: Rebase on the formatted main branch

git rebase --empty=drop tmp/fmt-applied

Git will now apply our changes onto the commit that formatted all the files. In doing so, it will notice that the changes in the first commit of this branch are the same as the ones in the last commit of the main branch. It will skip this commit with the message:

dropping SOME SHA ... -- patch contents already upstream

Step 4: Push the changes (with gentle force)

git push --force-with-lease

As we have changed all the commits in this branch we cannot simply push. That would lead to the push being rejected with “failed to push some refs”. So instead we push with force and lease lock. The --force-with-lease differs from the simple --force in that it checks that the stuff you are forcefully pushing is only replacing commits you know of. If someone else pushed a commit since you last pushed, the push --force-with-lease will fail (which is what you should want).