git-tricks(7)

2021-02-28 15 min read

NAME

git-tricks - A collection of tricks/tips for using git efficiently

SYNOPSIS

git push . origin/master:master, git commit --fixup, and many more!

DESCRIPTION

This is a random list of nice git features/tricks that I have found to be very useful but which a lot people don't seem to know about.

I will probably update this from time to time, so come back again if this is interesting to you :)

OPTIONS

DELETE YOUR MAIN/MASTER BRANCH

This is probably the most weird sounding advice I've ever given... But it is really useful!

If you're mostly working on feature-branches and only seldomly checking out the main/master branch, your local branch ref for it will most likely lag behind its remote counterpart (e.g. origin/main) quite often. This can lead to annoying mistakes like for example accidentally rebasing against master instead of origin/master or checking out the branch, expecting it will be up-to-date.

By just deleting the local branch ref with git branch -d master you can stop all of this from happening. With the branch deleted, a git rebase master will fail, not accidentally passing; or a git switch master will recreate it at the most recent commit from upstream (which is known locally from e.g. git fetch).

There is another trick that accomplishes the same thing: Instead of checking out the branch with git switch main, you can also use git switch -C main origin/main. While it is a bit more to type, this will switch to main and reset it to the latest version from upstream in one step. If you make it a habit to use this instead, it'll solve the problem just the same :)

DIFF AGAINST THE UPSTREAM VERSION OF A BRANCH

Suppose you're working on a feature in a branch. You've pushed it to a project and opened a PR/MR for it. Now, after code-review, you went back to it, did some changes, maybe updated some commits to reflect the requested changes to your code.

Before sending it off, it is nice to take another look over the changes since the version which is currently online. The easiest way to do this is of course git diff origin/branch-name. But there is actually a shorter one, which doesn't even need you to look up the branch name! It looks like this:

git diff @{u}

The @{u} denotes the upstream ref for the current branch as explained in more details in gitrevisions(7).

DISPLAY A GRAPH OF THE HISTORY

Sometimes it's useful to see the history not in the linearized form which git log shows by default, but to see all branching and merging that happened. Sure, tools like tig and gitk can do this, but actually we don't need to go that far: I found a Stack Overflow answer with a super nice alias which does exactly this. Here it is (slightly modified):

[alias]
	lg = !"git lg-specific --all"
	lgs = !"git lg-specific"

	lg-specific = log --graph --abbrev-commit --decorate --format=format:'%C(bold blue)%h%C(reset) - %C(bold green)(%ar)%C(reset) %C(white)%s%C(reset) %C(dim white)- %an%C(reset)%C(bold yellow)%d%C(reset)'

git lg will now show a graph with all branches in your repository while git lgs will only show the graph of ancestors of the current branch. Here is an example:

*   558002a0f223 - (12 days ago) Merge https://source.denx.de/u-boot/custodians/u-boot-riscv - Tom Rini (HEAD -> master)
|\  
| * c0ffc12a7016 - (3 weeks ago) riscv: Enable SPI flash env for SiFive Unmatched. - Thomas Skibo
| * 6a863894ad53 - (3 weeks ago) riscv: Support booting SiFive Unmatched from SPI. - Thomas Skibo
| * ffb78a7c71d5 - (4 weeks ago) doc: board: Update Microchip MPFS Icicle Kit doc - Padmarao Begari
| * 5c007d24b9ae - (4 weeks ago) riscv: Update Microchip MPFS Icicle Kit support - Padmarao Begari
| * 06142d6874ca - (4 weeks ago) riscv: dts: Split Microchip device tree - Padmarao Begari
| * 0dc0d1e09441 - (4 weeks ago) i2c: Add Microchip PolarFire SoC I2C driver - Padmarao Begari
| * 0d914ad10da3 - (4 weeks ago) net: macb: Remove Microchip compatible string - Padmarao Begari
| * 666da85dc989 - (6 weeks ago) board: ae350: Support autoboot from RAM - Leo Yu-Chi Liang
* |   5b9ee0168529 - (13 days ago) Merge branch 'master' of https://source.denx.de/u-boot/custodians/u-boot-net - Tom Rini
|\ \  
| |/  
|/|   
| * 3fbd17aadf79 - (5 weeks ago) net: dwc_eth_qos: Enable clock in probe - Marek Vasut
| * 877703372271 - (5 weeks ago) net: eth-phy: Handle gpio_request_by_name() return value - Marek Vasut
|/  
*   4a14bfffd42f - (2 weeks ago) Merge https://source.denx.de/u-boot/custodians/u-boot-marvell - Tom Rini

DON'T IGNORE ALL FILES WITH THE SAME NAME

Say you have the following line in your .gitignore:

target/

This will ignore ./target as intended, but it would also ignore ./src/foo/target as well which might be unwanted. This can be fixed by prefixing the pattern in .gitignore with a /:

/target/

Now, git will match this pattern from the root of the working tree so only ./target will actually get ignored.

In general I've started to just add the leading / to almost all entries in .gitignore, just in case a file with a matching name ever shows up in the wrong place - this way it will be visible in git status and I can investigate what happened.

The gitignore(5) manpage explains all the detailed semantics of the format in .gitignore and friends.

DON'T PULL THE gh-pages BRANCH

On GitHub you can host a website for a project by pushing the webcontent to a branch named gh-pages. This is a very convenient feature, especially because you can setup CI to automatically build documentation and push it to this branch.

There is a downside to this, though: When you clone a project like this and there is quite a lot of content on the gh-pages branch, this will slow down every git pull for no good reason. After all, almost nobody will ever look at the gh-pages branch locally.

Luckily you can make git not pull a certain branch/ref! Just run the following commands in your local checkout:

# Instruct git to never pull gh-pages again
git config --local --add remote.origin.fetch ^refs/heads/gh-pages

# Remove all leftovers of the remote branch in the local repository
git branch -rd origin/gh-pages

EDIT PREVIOUS COMMITS EASILY

This was a gamechanger for me when I first learned about it. I am a strong advocate of keeping a project's commit history "clean", in the sense that each commit should be a sensible and standalone change. To create such clean changesets, one often needs to edit a previously created commit. If it is the last one, this can be done with git commit --amend but for anything before it, one has to resort to git rebase --interactive and then marking said commit as edit. This works but is cumbersome because you're constantly jumping around in the history and there also isn't any way to track the "changes to changes" you made.

Enter git commit --fixup and git commit --squash: When you notice you need to do some more changes which really belong in a certain past commit, you stage the new changes like normal and then do a

git commit --fixup="sha1 of past commit to amend"

this will create a new commit with the message prefixed by fixup!. Once you think you've got everything right, you can fold these fixup commits into their referenced commit by doing

git rebase --interactive --autosquash

This was a huge improvement to my workflow: I can now linearly work on my changeset, making fixup commits as a go along and once I am done, I can more or less automatically fold all those "development commits" into a clean set of commits which each do one logical change.

LIST LINES PER AUTHOR

If you are curious how many lines of code in the current version of your codebase can be attributed to each author, this shell-pipeline does the trick:

git ls-files -z | xargs -0n1 git blame -w --line-porcelain | sed -n 's/^author //p' | sort | uniq -c | sort -nr

IGNORE A FILE WITHOUT ADDING IT TO GITIGNORE

Sometimes while hacking on a project you create files in a repository's working tree which aren't meant to be commited nor will anyone else ever have similarly named files around. They show up in git status which is annoying but adding them to .gitignore is not an option because nobody else cares about these filenames.

Instead, just add them to the .git/info/exclude file! It has the same syntax as .gitignore but is not part of the working tree so it just applies to this one local checkout of a repository.

NEVER ATTEMPT TO MERGE DURING A PULL

You're working on a branch, did some commits and in the meantime someone else pushed their own commits to the same branch upstream. Now you git pull and for some odd reason the default behavior of git is to try and create a merge commit.

To me, this is the worst it can do. In most situations, you didn't expect this to happen so you didn't intent to get this ugly merge into your history. I would say that in most situations, taking a step back and then rebasing is probably the much saner choice.

We can prevent git from automatically attempting the merge and instead just failing with noise by setting the following configuration:

git config --global pull.ff only

PULL A BRANCH WITHOUT CHECKING IT OUT

Maybe it's just me but for some reason I use this quite often: Say you're on a branch doing work and you want to pull your local master up to its latest remote commit. You could go and check the branch out, then git pull and then switch back but that's of course cumbersome and also doesn't work when you have unstaged changes in the working directory.

Instead, I learnt of the following neat little trick:

# First update all remote refs
git fetch

# Now update the local master to its remote counterpart
git push . origin/master:master

This works because using . in the place of the remote for git push is interpreted as the current directory. So it is pushing origin/master to the master branch in the git repository of the current directory.

WRITE COMMIT MESSAGES LIKE A PRO

For some reason, most people are taught to commit with

git commit -m "commit message goes here"

This sadly perpetuates the bad practice of only writing single-line commit messages. I won't go into details here, but it is a very good idea to include a longer description of why a changes was made and what is changed, at least for any non-trivial changesets. The Describe your changes section from the kernel patch submission document has some nice tips.

If you leave out the -m "message" part of a git commit command, git will instead open your favorite editor (selected via the $EDITOR environment variable) to let you compose a commit message.

Even better, you can instruct git to attach the diff that you're about to commit to this file so you can see it while writing your commit message. To do this, enable the following configuration:

git config --global commit.verbose true

If you're using vim, it even has proper syntax highlighting for the diff (with the gitcommit filetype) :)

Even better, again for vim/neovim users, you can automatically open the diff in a split so you can see it side-by-side to the window where you're composing the commit message. I found a gist by @aroben for this which I later heavily modified. With all this in place, writing a commit message looks like this for me:

Workflow of writing a commit message with vim and diff split

The modified version of @aroben's gist which I am currently using looks like this (beware ugly vim hacks):

function! CommitPencil() abort
    setlocal comments=:#,n:>,f:[

    setlocal textwidth=0
    call CustomPencil({
        \ 'wrap': 'hard',
        \ 'textwidth': 72,
        \ })

    setlocal nonumber norelativenumber
    setlocal nofoldenable
endfunction

" originally from https://gist.github.com/aroben/d54d002269d9c39f0d5c89d910f7307e
" heavily modified for my personal needs
"
" TODO: make this work with fugitive
function! OpenCommitMessageDiff()
    " if the window is too small, don't do anything
    if &columns < 170
        return
    endif

    " if we already added a split, ignore
    if exists('b:did_commit_split')
        return
    endif
    let b:did_commit_split = 1

    " save the contents of the z register
    let l:old_z = getreg('z')
    let l:old_z_type = getregtype('z')

    " save window id of the current window
    let l:winid_commitmsg = win_getid()

    try
        call cursor(1, 0)
        let diff_start = search('^diff --git')
        if diff_start == 0
            " there's no diff in the commit message; generate our own.
            let @z = system('git diff --cached -M -C')
        else
            " yank diff from the bottom of the commit message into the z register
            silent execute ':.,$yank z'
            call cursor(1, 0)
        endif

        " paste into a new buffer
        setlocal nosplitright
        vnew
        silent normal! V"zP
    finally
        " restore the z register
        call setreg('z', l:old_z, l:old_z_type)
    endtry

    " configure the buffer
    setlocal filetype=diff noswapfile nomodified readonly
    silent file [Changes\ to\ be\ committed]
    setlocal nofoldenable
    let g:git_winid_diff = win_getid()

    " get back to the commit message
    call win_gotoid(l:winid_commitmsg)

    autocmd QuitPre <buffer> execute win_id2win(g:git_winid_diff)."wincmd c"
endfunction

augroup filetype_git
    autocmd!
    autocmd VimEnter COMMIT_EDITMSG call OpenCommitMessageDiff()
    autocmd BufRead EDIT_DESCRIPTION setfiletype gitcommit
    autocmd FileType git,gitsendemail,*commit*,*COMMIT* call CommitPencil()
augroup END

SEE ALSO

I really recommend reading through some of the git manpages like git-commit(1), git-add(1), git-rebase(1), gitrevisions(7), or git-config(1). There are a lot of really useful gems hidden in there, some commandline options/config settings which you might find super useful. Also, catching up on the release notes whenever a new git version is released often helps to learn some cool new features :)