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 APIbafa52
- test: implemented coverage for league fetching647500
- fix: handle empty leagues response gracefullyb0fad4
- docs: mention new endpoint in README and link Datadog dashboard
With this one:
cb0fae
- kitchen sink11d2ad
- WIP 256d5d1
- fix56d5d1
- 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 functionalityfix
- make something broken worktest
- 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
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
:
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:
alias gsp=git push origin $(git rev-parse --abbrev-ref HEAD)
alias gfp=git push origin --force $(git rev-parse --abbrev-ref HEAD)
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
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).
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:
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
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
).
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"
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'