Commit Hygiene

Published on

Be kind to yourself

Even if you're just working on a project by yourself, readability matters.

Compare reading this set of commits:

  • abcd31 - feat: added leagues API
  • bafa52 - test: implemented coverage for league fetching
  • 647500 - fix: handle empty leagues response gracefully
  • b0fad4 - docs: mention new endpoint in README and link Datadog dashboard

With this one:

  • cb0fae - kitchen sink
  • 11d2ad - WIP 2
  • 56d5d1 - fix
  • 56d5d1 - update readme

If you're trying to go find the commit that introduced a regression later on, it's much harder to guess which commits may have changed the code in question.

...but be kinder to others

Writing useful messages matters a lot more if you're working on a project with any of the following:

  • a distributed team
  • different working hours
  • many hands touching shared services
  • different native languages spoken

Here's a few tips and commands for keeping commit messages clear and useful.

Format your commit messages

The first time I saw someone writing commits using standardized formatting, I nearly lost my mind.

Conventional commits might be more than most developers need, but even starting with a subset can clarify what kind of work is being done.

For example:

  • feat - adding new functionality
  • fix - make something broken work
  • test - add coverage (shouldn't affect running code)
  • docs - comment or add explanations to .md files (shouldn't affect running code)
  • chore - other less feature oriented updates like dependency version bumps

Reading git history comprised of the above, it's much easier to find a regression if I can know that messages prefixed with docs and test do not change code execution.

Prefer tidier commits

Ever want to see what the current diff is in little snippets? git add -p (short for --patch) is your friend. It allows you to page through uncommitted diffs, and then stage or ignore them.

> git add -p

...

(1/2) Stage this hunk [y,n,q,a,d,j,J,g,/,e,?]? ?
y - stage this hunk
n - do not stage this hunk
q - quit; do not stage this hunk or any of the remaining ones
a - stage this hunk and all later hunks in the file
d - do not stage this hunk or any of the later hunks in the file
j - leave this hunk undecided, see next undecided hunk
J - leave this hunk undecided, see next hunk
g - select a hunk to go to
/ - search for a hunk matching the given regex
e - manually edit the current hunk
? - print help

Typing ? after running git add --p shows available subcommands

Even if you just run the command and add no changes (skipping through with n), this is a super easy way to give yourself context on what your current diff is directly from your terminal.

Using --patch (and occasionally s to split hunks) can lead to smaller, more orderly sets of changes that can be leafed through quickly during review.

If you want to practice staging patches, you can also unstage changes using git restore --staged <file> (or use git restore --staged . to affect all staged files in your current directory).

Augment recent commits

If you missed a spot, it can be helpful to add additional changes to your most recent commit.

For brevity, you can add an alias like the following in your .bashrc or .zshrc:

~/.zshrc
alias gcane=git commit --amend --noedit

This command will take whatever staged changes you have and combine them with the latest commit. Omitting the --noedit flag will allow you to change the commit message "in place" using your terminal's editor.

Heads up!

Using this command will change the SHA of your most recent commit. If you've already pushed changes to a remote, you'll need to do something more interesting to get the altered commit to the remote repository.

Push (and force push)

If you're touching up or squashing commits, you'll need to be able to dangerously push updates after changing local history. git provides reasonable guardrails that typically stop users from destroying history, but --force can be used to go around those protections.

I have the following aliases set up to make safe pushing and force pushing based on my current branch name easier:

~/.zshrc
alias gsp=git push origin $(git rev-parse --abbrev-ref HEAD)
alias gfp=git push origin --force $(git rev-parse --abbrev-ref HEAD)

$(git rev-parse --abbrev-ref HEAD) grabs the name of your current branch

So these basically read "politely try to push my local commits to the corresponding branch", and "DESTRUCTIVELY push local commits to the corresponding branch". Here's what safely pushing looks like when commits between my local branch and remote diverge:

➜  gc2 git:(take-two-rss) gsp
To github.com:sgardn/gc2.git
 ! [rejected]        take-two-rss -> take-two-rss (non-fast-forward)
error: failed to push some refs to 'github.com:sgardn/gc2.git'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. Integrate the remote changes (e.g.
hint: 'git pull ...') before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.

I use gsp normally, and then only use gfp if it fails and I'm sure I've updated local commits in place / rebased interactively for cleaner history. gfp is a lot safer, however, if you are in the habit of forking repos and only pushing to your fork (when working with other developers).

Warning!

Force pushing *will* blow away history in your remote repo!

Be cautious if you're in the habit of fetching branches from your teammates' forks - as configured above, you should be fine unless you name your teammates' repos origin. You could also consider having branch protection configured as a precaution, so only the fork owner can --force push.

The git documentation contains more context and options.

Interactively rebase

It can be helpful to locally rewrite history before getting pull request review. Squashing (combining) and reordering commits can be done locally using rebase:

git rebase -i main

Using -i (for --interactive) allows you to make changes "in place"

git will show you a file using your shell's EDITOR (in my case nova) that shows your current branch's changes compared to the target branch (which is main in this case).

{REPO_DIR}/.git/rebase-merge/git-rebase-todo
pick e796ea8 fix: revert RSS description to be post.summary, since we do not have HTML available
pick bb1eb21 chore: remove committed tag-data.json
pick cd707db chore: gitignore app/tag-data.json
pick 85fa5bf blog: polishing lifecycle of product work
pick a6e95bb docs: commit hygiene polish
pick c708070 style: compress ConfluenceBox title spacing

# Rebase 1aa9db8..c708070 onto 1aa9db8 (6 commands)
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup [-C | -c] <commit> = like "squash" but keep only the previous
#                    commit's log message, unless -C is used, in which case
#                    keep only this commit's message; -c is same as -C but
#                    opens the editor
# x, exec <command> = run command (the rest of the line) using shell
# b, break = stop here (continue rebase later with 'git rebase --continue')
# d, drop <commit> = remove commit
# l, label <label> = label current HEAD with a name
# t, reset <label> = reset HEAD to a label
# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
#         create a merge commit using the original merge commit's
#         message (or the oneline, if no original merge commit was
#         specified); use -c <commit> to reword the commit message
# u, update-ref <ref> = track a placeholder for the <ref> to be updated
#                       to this position in the new commits. The <ref> is
#                       updated at the end of the rebase
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.
#

If I wanted to combine the two commits containing markdown changes, AND I wanted to tweak the commit message to prefer docs instead of blog, I could update the file contain the following before saving and closing:

{REPO_DIR}/.git/rebase-merge/git-rebase-todo
pick e796ea8 fix: revert RSS description to be post.summary, since we do not have HTML available
pick bb1eb21 chore: remove committed tag-data.json
pick cd707db chore: gitignore app/tag-data.json
reword 85fa5bf blog: polishing lifecycle of product work
squash a6e95bb docs: commit hygiene polish
pick c708070 style: compress ConfluenceBox title spacing

git will then reopen my editor at each stage requiring input so I can adjust messages as necessary. Squashing doesn't require input, but rewording does. Saving and closing each file will complete the required steps, at which point the original command should resolve.

Successfully rebased and updated refs/heads/take-two-rss.

Undo recent changes

There isn't really an "undo" command, but git reset is the next best thing. The following command "uncommits" your most recent changes and leaves them as staged:

git reset --soft @~1

@~1 can be read as "the commit before the most recent one"

A more volatile version is git reset --hard @~1, which destorys history entirely and sets the current HEAD to point to the previous commit (leaving no staged changes).

I don't keep aliases for these lying around. It's too easy to blow away history or accidentally dig up valid distinct commits by hitting "up enter" an extra time.

Wrapping up

Congrats! You made it to the end. A few other fun git tidbits:

The following function enables the briefest possible status checking (and also aliases git to g).

~/.zshrc
function g() {
  if (( !$# )) ; then
    git status -b
  else
    git $@
  fi
}

This alias sets up much prettier log messages:

git config --global alias.lg "log --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' --abbrev-commit"

Invoked with g lg, given the above bash function

This alias takes you to the root directory of a git repository:

alias groot='cd $(git rev-parse --show-toplevel)'

And this alias blows away changes since your last commit:

alias gclean='git clean -fd'