Practising Git hygiene with fixup commits
Tags: github, git, webdev
Published: 2024-02-04 12:01:35

Git takes a long time to become comfortable with. Still now, years after I first used it, I hold my breath doing rebases and cross my fingers there are no conflicts. It is not controversial to say Git is unintuitive, with strangely named commands and a lacking command line interface. Unfortunately, we're stuck with it for the time being...

"Git hygiene" refers to good organisation of a Git repository and its commit history. Commits act as documentation and are often used to generate release notes, so it is important they describe changes accurately. For me, each commit should represent the project in a working state. I should be able to checkout something from a couple weeks ago and run the project without error. That said, it is common to see the following in pull requests:

- `feat(Button): add size variant`
- `chore: fix linting`
- `fix: pull request feedback`
- `fix: failing test`

There are a few problems with the above:

1. Too many commits. In the context of a PR the commits _are_ useful. The reviewer can understand how and why the branch is changing. Outside of the pull request, though, that information is not useful. Typically, developers will squash commits to address this problem, ensuring the final commit message is appropriate. In itself that presents further problems:
   - What if we want multiple final commits in a pull request? We may have a larger pull requests, with logically separated commits, each of them able to be individually reverted.
   - What if the commits themselves are under scrutiny by the code reviewer and/or automation?
2. If I checkout the first commit linting and tests will fail. Not a problem if the commits are squashed, but if they aren't, our `main` branch is polluted with broken commits. Later on, identifying which commit introduced an issue becomes harder.
3. With each round of code review further changes will need new commits, likely meaning the commit messages get sloppier and less legible. Following something like [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) is wasted cognitive load when we know we are squashing the commits anyway.

Fixup commits offer us an alternative here. Let's start with the commit:

1. `feat(Button): add size variant`

Imagine I open the pull request and an automated workflow tells me there are lint issues. Instead of naming a new commit I can run:

```
git commit --fixup [COMMIT_SHA]
```

Which results in:

1. `feat(Button): add size variant`
2. `fixup! feat(Button): add size variant`

When I first saw that I thought it was perfect. It indicates exactly my intentions to the the reader: I want one commit, but I am "fixing" it with further revisions. Now, we could rebase locally and push only a single commit back to the remote branch, but in my experience GitHub doesn't like that. In the case of some feedback being addressed I want to compare the previous state to the new one and I need multiple commits for that.

Still, we do need to squash the commits before merging since we don't want them ending up on `main`. Thankfully, we can do:

```
git rebase main -i --autosquash
```

This applies all the fixup commits, resulting in one (or more) clean and hygienic commits that are ready to be merged. I do wish GitHub would integrate this functionality into its user interface. Currently that command must be run locally, instead of using the "Rebase and merge" button.