The Valuable Dev

A Practical Guide to fzf: Building a Git Explorer

C3PO and RD2D obviously use fzf interfaces with Git

It’s again a boring day in the offices of MegaCorpMoneyMaker, the company you’re working with. As often you’re writing spaghetti code, not because you’re a bad developer, but because your deadlines are incredibly short, your motivation non-existent, and your colleagues care more about their promotions than their actual work.

Looking out of the window while questioning your life choices, you suddenly see a globular and reflecting flying saucer landing on the parking of the company. Flabbergasted, you begin to wonder if these aliens want to buy the horrible products MegaCorpMoneyMaker is selling. Quite disappointed to see aliens with such a lack of taste, you decide to see by yourself what these creatures want.

From the flying saucer comes creatures you have a hard time to describe, even to yourself. They’re like cones with cyclopean tentacles, covered by tiny holes opening and closing randomly, like hundreds of little mouths trying to say something. Nobody is around, you’re alone with these nightmares.

Suddenly, you hear a voice in your head:

“Git is such a nightmare to use. Write some interfaces we can customize easily, earthling! We don’t like GUI, it’s not configurable enough, and we don’t like to use a mouse. Our tentacles can’t grab it easily.”

Wondering what they’ll do to you if you refuse, you quickly have the answer pumping in your head:

“Do you want to see the heart of a star, earthling? The bottom of a black hole? Do you want to travel in different plans, full of creatures even uglier than you?”

You realize suddenly that these propositions don’t sound too appealing. Working in MegaCorpMoneyMaker is not that bad, at the end.

You come back to your office, grab your laptop, and begin to work. This day was written in the Memory of a Charming Alien, a very famous book every alien in the universe read at least once, even these rednecks on Titan. This article is a transcription of the work done in this day.

More precisely, we’ll see:

  • How to create an interface with fzf to manage files with Git.
  • How to create an interface with fzf to manage Git commits.
  • How to create an interface with fzf to manage Git branches.

We’ll build these interfaces step by step: we’ll first define how to display the information we’re interested in, to then add keystrokes to perform some useful operations using the Holy Git.

There is no guarantee that these commands work in any situation, but they’re explained enough for you to improve and customize them depending on your needs and preferences.

The goal of this article is to show you how far we can go with fzf, some useful Git commands you might not be aware of, as well as some general tips about shell scripting in general. I won’t explain everything related to Git here, but if you’re interested to see a series of article covering this amazing tool, don’t hesitate to connect, or to let a comment at the end of this page.

If you’re lost in the different fzf options we’ll use in this article, they’re all explained in the first article of this series.

Are you ready to interface the sourcing power of Git with the listing energy of fzf?

The Article Companion

To get the most of this article, I’d recommend you to follow along and fidget with the different commands we’ll discuss here. We’ll use a dummy repository to test out the different interfaces we’ll create; you just need to clone it with the following shell command:

git clone https://github.com/Phantas0s/tvd_companion_git_fzf

We’ll call this project the article companion.

From there, simply open a terminal, go to the folder tvd_companion_git_fzf, and you’re good to go. All the commands we’ll see in this article work in Bash (tested with GNU bash, version 5.2.26) and other similar shells like Zsh. I used fzf 0.48.

Working with Files

The files are your project are the most important entities when it comes to version control. Let’s build an interface to perform the most common operations in a typical Git workflow.

Preparing the Terrain

To stage and unstage files using Git, we need first to create, modify, and delete some of them in our article companion. Let’s run the following shell commands:

touch 1.md 2.md
rm DUMMY.md
echo 'New line!' >> README.md

If you run git status --short you should get the following output:

 D DUMMY.md
 M README.md
?? 1.md
?? 2.md

If you’re not familiar with this output, let me explain quickly:

  • Each file are prefixed with two columns, indicating the status of the file.
  • The prefix ?? indicates that the file is untracked by Git. It has never been committed yet.
  • The prefix D indicates that the file has been deleted. Because the D is in the second column of the prefix, we know that this deleted file is unstaged.
  • The prefix M indicated that the file was modified. It’s also in the second column of the prefix, so we know that it’s unstaged.

If we run the command git add DUMMY.md, the deletion becomes staged; the status D is now in the first column of the prefix:

D  DUMMY.md
 M README.md
?? 1.md
?? 2.md

Let’s unstage the deletion: run git reset DUMMY.md.

Git manual
man git-status - Search for “Changed Tracked Entries”

Listing Staged and Unstaged Files

One of the most common thing we can do with Git is staging and unstaging files, thanks to the shell command git add and git reset respectively.

We could imagine using fzf to get the list of unstaged files, select the files we want to stage, and run git add on the selection.

Listing the Unstaged Files

First, we need to list the unstaged files. We could use git ls-files to get the files modified, deleted, and untracked (“other”). We should also skip the files listed in .gitignore:

git ls-files --modified --deleted --other --exclude-standard

Here’s what you should get:

1.md
2.md
DUMMY.md
DUMMY.md
README.md

Deleted and unstaged files are considered both modified and deleted, that’s why DUMMY.md appears two times here. We need to add the option --deduplicate to fix this problem:

git ls-files --modified --deleted --other --exclude-standard --deduplicate

The magical output:

1.md
2.md
DUMMY.md
README.md

But this command is not perfect for our use case. To illustrate the problem, run cd subdir to change our current directory to a subdirectory of the article companion. If we run again the above Git command, there won’t be any output.

It’s because git ls-files look at the current directory by default, not the root directory of a project. We could give this root directory as argument to fix this problem. Good news everyone: the following command output what we need:

git rev-parse --show-toplevel

If we run the following, we’ll always have the unstaged files we need, even if we’re in a subdirectory of a project:

git ls-files \
    --modified \
    --deleted \
    --other \
    --exclude-standard \
    --deduplicate \
    $(git rev-parse --show-toplevel)

The output:

../1.md
../2.md
../DUMMY.md
../DUMMY.md
../README.md

Listing the Staged Files

If we want a fzf interface to also unstage files, we need a list of all staged ones. First, let’s stage a file in our article companion:

git add ../1.md

The following command can display the list of staged files:

git diff --name-only --staged

The output:

1.md

But, again, we have a problem: we’re still in the subdirectory subdir, so the path 1.md is not correct. Running git add 1.md won’t work; instead, we need the list of the relative filepaths of our staged files, to be able to do something like git add ../1.md for example.

An alternative would be to use git status to list all the staged files and get the good filepaths, but it gets a bit more complicated:

git status --short | grep '^[A-Z]' | awk '{print $NF}'

We basically list here all the staged and unstaged files, and only grep the ones which have a letter in the first column of their prefixes (and therefore which are staged). We also use the CLI tool awk to get the name of the files without the prefixes. As you can see, we get what we want:

../1.md

An interface to Stage and Unstage Files

Let’s combine the commands we’ve seen above with fzf. First, let’s create an interface to stage files:

git add $(git ls-files \
    --modified \
    --deleted \
    --other \
    --exclude-standard \
    --deduplicate \
    $(git rev-parse --show-toplevel) \
  | fzf --multi --reverse --no-sort)

Here’s another command to unstage files:

git reset -- $(git status --short \
  | grep '^[A-Z]' \
  | awk '{print $NF}' \
  | fzf --multi --reverse --no-sort)

We add -- here to specify to Git that we want to reset files, not commits.

If we don’t want to quit fzf when adding one or multiple files, we could also bind the ENTER key to add the files. Let’s also add a preview showing the status of all the project’s files, a nice prompt, and some help:

staged_files='git ls-files \
  --modified \
  --deleted \
  --other \
  --exclude-standard \
  --deduplicate \
  $(git rev-parse --show-toplevel)' \
&& eval "$staged_files" | fzf \
  --multi \
  --reverse \
  --no-sort \
  --prompt='Add > ' \
  --header-first \
  --header='ENTER to stage the files' \
  --preview='git status --short' \
  --bind='enter:execute(git add {+})' \
  --bind="enter:+reload($staged_files)"

If you don’t understand what all these options stand for, the first article of this series can help you.

The result:

fzf interface to stage files using Git

From there, it’s easy to come up with a similar command to unstage files:

unstaged_files='git status --short \
  | grep "^[A-Z]" \
  | awk "{print \$NF}"' \
&& eval "$unstaged_files" | fzf \
  --multi \
  --reverse \
  --no-sort \
  --prompt='Reset > ' \
  --header-first \
  --header='ENTER to unstage the file' \
  --preview='git status --short' \
  --bind='enter:execute(git reset -- {+})' \
  --bind="enter:+reload($unstaged_files)"

We have to escape the $ of $NF using a backslash (\$NF) because we’re using weak double quotes here, and we don’t want the shell to expand $NF; awk should do that instead. When we begin to add layers of quoting in Bash, we run quickly into nasty problems. This trope will follow us until the end of this article, and possibly the end of time.

All of that is great, but we have now two different interfaces to stage and unstage our files. What about having only one interface to rule them all? We could switch between the “Add mode” and the “Reset mode” with a couple of keystrokes.

What should happen when we hit ENTER? If the files can be found in the list of unstaged files, we stage them; otherwise, we unstage them. Something like the following:

git ls-files --modified --deleted --other --exclude-standard --deduplicate | grep {} \
&& git add {+} \
|| git reset -- {+}

Here’s a possible implementation:

staged_files='git ls-files \
  --modified \
  --deleted \
  --other \
  --exclude-standard \
  --deduplicate \
  $(git rev-parse --show-toplevel)' \
&& unstaged_files='git status  --short \
  | grep "^[A-Z]" \
  | awk "{print \$NF}"' \
&& eval "$staged_files" | fzf \
  --multi \
  --reverse \
  --no-sort \
  --prompt='Add > ' \
  --header-first \
  --header '
  > CTRL-R to Reset | CTRL-A to Add
  > ENTER to Reset or Add files
  > ENTER in Reset mode switch back to Add mode
  ' \
  --preview='git status --short' \
  --bind='ctrl-a:change-prompt(Add > )' \
  --bind="ctrl-a:+reload($staged_files)" \
  --bind='ctrl-r:change-prompt(Reset > )' \
  --bind="ctrl-r:+reload($unstaged_files)" \
  --bind="enter:execute($staged_files | grep {} \
    && git add {+} \
    || git reset -- {+})" \
  --bind='enter:+change-prompt(Add > )' \
  --bind="enter:+reload($staged_files)" \
  --bind='enter:+refresh-preview'

As explained in the header, we can switch to “Add mode” by hitting CTRL-a, and to “Reset mode” by hitting CTRL-r.

Each time we reset one (or multiple) files, we come back to “Add mode”. It’s because it’s difficult to know in what mode we’re in, and, as a result, it’s difficult to know what shell command we should use to reload the list of entries in fzf when we stage or unstage files. Always being in “Add mode” allows us to avoid this problem; we just have to reload the list of unstaged files. But it’s not ideal.

It’s also a bit annoying to have two different keystrokes for our two modes. What about having one keystroke to switch between them? It’s where the transform action can help us:

staged_files='git ls-files \
  --modified \
  --deleted \
  --other \
  --exclude-standard \
  --deduplicate \
  $(git rev-parse --show-toplevel)' \
&& unstaged_files='git status --short \
  | grep "^[A-Z]" \
  | awk "{print \$NF}"' \
&& eval "$staged_files" | fzf \
  --multi \
  --reverse \
  --no-sort \
  --prompt='Add > ' \
  --header-first \
  --header '
  > CTRL-S to switch between Add and Reset mode
  > ENTER to Reset or Add files
  ' \
  --preview='git status --short' \
  --bind="ctrl-s:transform:[[ \$FZF_PROMPT =~ 'Add >' ]] \
    && echo 'change-prompt(Reset > )+reload($unstaged_files)' \
    || echo 'change-prompt(Add > )+reload($staged_files)'" \
  --bind="enter:execute(
    $staged_files | grep {} \
    && git add {+} \
    || git reset -- {+}
    )" \
  --bind="enter:+reload(
    [[ \$FZF_PROMPT =~ 'Add >' ]] \
    && $staged_files \
    || $unstaged_files
    )" \
  --bind='enter:+refresh-preview'

The sumptuous result:

The internal variable $FZF_PROMPT store the string used as prompt in fzf (Add > or Reset > here). We can look at its value to know in what mode we’re in; as a result, we can refresh the good list of files (staged or unstaged) each time we hit CTRL-s or ENTER.

Multiple Preview

It’s great to display the status of our files in fzf’s preview, but it would also be useful to show the diff of these files, to know what was modified at a glance.

To display the diff of the file ../README.md for example, we can run the following command:

git diff --color ../README.md

The output:

────────────────────────────────────
modified: README.md
────────────────────────────────────
@ README.md:1 @
This is the companion repository for the article [A Practical Guide to fzf: Building a Git Explorer](https://thevaluable.dev/fzf-git-integration).
New line!

The first four lines of the diff are quite useless for our use case, so let’s use the CLI tool sed to get rid of them:

git diff --color=always archives/outlines/fzf-git-integration.md \
| sed '1,4d'

If you want to know more about sed, I’ve written an article about it.

You might not want to display the diff at all, but only some stats. You could do the following in that case:

git diff --color --stat ../README.md

As always, it depends on what you prefer. For example, we could also add --diff-algorithm=histogram to avoid repeating some changes in common elements. Also, the options --ignore-all-space and --ignore-blank-lines can be useful if you don’t want to bother with diff of spaces and blank lines.

Here are the options we can add to our interfaces to change the preview:

  --bind='ctrl-f:change-preview-label([ Diff ])' \
  --bind='ctrl-f:+change-preview(git diff --color=always {} | sed "1,4d")' \
  --bind='ctrl-s:change-preview-label([ Status ])' \
  --bind='ctrl-s:+change-preview(git status --short)' \

Unfortunately, it seems that there is no way to use only one keystroke to switch between the two previews. There is no environment variable like $FZF_PROMPT which contains the label of the preview, to switch to the other one. But fear not! I’ve open a pull request to add a new environment variable $FZF_PREVIEW_LABEL; if it’s ever merged, something like the following would then be possible:

  --bind="ctrl-p:transform:[[ \$FZF_PREVIEW_LABEL =~ '[ Status ]' ]] \
    && echo 'change-preview(git diff --color=always {} | sed \"1,4d\")+change-preview-label([ Diff ])' \
    || echo 'change-preview(git status --short)+change-preview-label([ Status ])'" \

We can also add another keystroke to display a preview of git blame:

 --bind='ctrl-b:change-preview-label([ Blame ])' \
 --bind='ctrl-b:+change-preview(git blame --color-by-age {})' \

Now that our preview can be quite long, it would be nice to have some keystrokes to scroll it. We can add the following ones for example, mimicking some good old Vim keystrokes:

--bind='ctrl-y:preview-up' \
--bind='ctrl-e:preview-down' \
--bind='ctrl-u:preview-half-page-up' \
--bind='ctrl-d:preview-half-page-down' \

Adding Patches

If you have multiple changes in a single file, and you want to commit some of these changes but not all of them, you can choose and add hunks of patch thanks to git add --patch. Let’s add this functionality to our splendid interface:

--bind='alt-p:execute(git add --patch {+})' \
--bind='alt-p:+reload($unstaged_files)' \

Editing the Files

What about editing the files selected in your favorite editor (which is obviously Vim) directly from fzf? We can add the following keystrokes to do so:

--bind 'alt-e:execute(${EDITOR:-vim} {+})' \

We use the environment variable $EDITOR to find your favorite editor and edit the files selected. If the variable is empty we default to Vim.

Checkout the Files

If you want to undo all the changes you’ve made since the last commit in some files, you simply need to checkout them. We could add this functionality to our interface: first, we reset the file in case it was staged, and then we undo all the modifications we did in the last commit. Something like the following:

--bind='alt-d:execute(git reset -- {+})' \
--bind='alt-d:+execute(git checkout {+})' \

To commit or not to Commit

Lastly, it would be nice to create commits directly from our interface. Let’s not wait any longer:

--bind 'alt-c:execute(git commit)+abort' \

We could also add the staged files to the last commit as follows:

--bind 'alt-a:execute(git commit --amend)+abort' \

As you can see, when the commit is created, we ask fzf to abort. Again, it’s up to you: you might not want to close fzf when you’ve committed your staged files.

A Script to rule them all

At that point, our command is quite a monster. It’s time to create a Bash script, to make our interface readily available, and also to refactor it a bit.

You can find the final result here. Let’s look a bit more closely at this implementation; first, we don’t allow all the keystrokes for each mode:

local -r mode_reset="change-prompt($prompt_reset)+reload($git_staged_files)+change-header($reset_header)+unbind(alt-p)+rebind(alt-d)"

local -r mode_add="change-prompt($prompt_add)+reload($git_unstaged_files)+change-header($add_header)+rebind(alt-p)+unbind(alt-d)"

As you can see, we unbind ATL-p when we are in “Reset mode”, and we unbind ALT-d when we are in the “Add mode”. It’s because we don’t want to enable git add --patch in “Reset mode”, and we don’t want to enable git checkout in Add mode.

Since we’re directly in “Add mode” when we start fzf, we also need to unbind ALT-d at startup:

--bind='start:unbind(alt-d)' \

We also use some heredoc to make long strings and commands more manageable. For example:

	local -r header=$(cat <<-EOF
		> CTRL-S to switch between Add Mode and Reset mode
		> CTRL_T for status preview | CTRL-F for diff preview | CTRL-B for blame preview
		> ALT-E to open files in your editor
		> ALT-C to commit | ALT-A to append to the last commit
		EOF
	)

We use here TAB characters for our indentation coupled with <<-, which will ignore any leading TAB character. It means that the string itself won’t have any indentation.

If you don’t use TAB characters as indentation in your file, and you don’t want any indentation in the string itself, you can do the following:

    local -r header=$(cat <<EOF
> CTRL-S to switch between Add Mode and Reset mode
> CTRL_T for status preview | CTRL-F for diff preview | CTRL-B for blame preview
> ALT-E to open files in your editor
> ALT-C to commit | ALT-A to append to the last commit
EOF
)

If you’re a blessed Vim user and if you want to replace all your indentations with tabs in your file, you can simply run the following:

:set noexpandtab shiftwidth=2 tabstop=2 | retab!

That’s it! We have now a functional interface to manage our files with Git. It’s time now to go a level higher in our adventure: let’s create a new interface to manage Git commits.

Working with Commits

Managing commits is also an important part in a typical Git workflow. Let’s see how we can use fzf to make the changes we want as easily as possible.

Listing Git Commits

If we want to manage our commits using fzf, we first need to list them. One commit per line would be ideal; each line should contain the commit hash, to be able to give it as argument to other Git commands.

The following list all the commit hashes of the current branch:

git log --format="%h"

The output:

27f62ab
73ce782
2716af8
a0df21f
008faec
c13db2b
21868b1
7c2636f

From there, we can add more information on each line to make these commits more understandable. For example, we can add:

  • The short form of the committer date (%cs).
  • The subject of the commit (%s).
  • The name of the reference; for example head, tags, or remote branch (%d).

Here’s the command we need:

git log --format='%h - %cs - %s%d'

The output:

27f62ab - 2024-03-22 - Revert the changes from a0df21f in DUMMY (HEAD -> new-branch, origin/main, origin/HEAD, main)
73ce782 - 2024-03-22 - Merge branch 'another_branch'
2716af8 - 2024-03-22 - Add more explanation to DUMMY
a0df21f - 2024-03-22 - Add more text to DUMMY (origin/another_branch, another_branch)
008faec - 2024-03-22 - Add more text to another_file
c13db2b - 2024-03-22 - Add another file
21868b1 - 2024-03-22 - Add README, subdir and a file, and DUMMY
7c2636f - 2024-03-20 - First commit

Let’s improve our display even further, by adding pretty colors, and a graph:

git log --graph --color --format='%C(white)%h - %C(green)%cs - %C(blue)%s%C(red)%d'

The output:

* 27f62ab - 2024-03-22 - Revert the changes from a0df21f in DUMMY (HEAD)
*   73ce782 - 2024-03-22 - Merge branch 'another_branch'
|\
| * a0df21f - 2024-03-22 - Add more text to DUMMY (origin/another_branch)
| * 008faec - 2024-03-22 - Add more text to another_file
| * c13db2b - 2024-03-22 - Add another file
* | 2716af8 - 2024-03-22 - Add more explanation to DUMMY
|/
* 21868b1 - 2024-03-22 - Add README, subdir and a file, and DUMMY
* 7c2636f - 2024-03-20 - First commit

It’s time to bring fzf in the party:

git log --graph --color \
  --format='%C(white)%h - %C(green)%cs - %C(blue)%s%C(red)%d' \
| fzf \
  --ansi \
  --reverse \
  --no-sort

What about displaying the changes of the selected commit in fzf’s preview? We can use git show to do so. For example:

git show --color 7c2636f

It means that we need to isolate the commit hash from each line of our graph, and give it to git show. Let’s consider the following line as an example:

| * 008faec - 2024-03-22 - Add more text to another_file

We could get the hash using grep here:

echo '| * 008faec - 2024-03-22 - Add more text to another_file' \
| grep -o "[a-f0-9]\{7\}"

We get our expected output, the commit hash:

008faec

If you want to know more about grep, I’ve written an article about it. Also, if you’re not comfortable with the basics of regular expression, you can look at this other article.

Let’s improve our interface with this new preview:

git log --graph --color \
  --format='%C(white)%h - %C(green)%cs - %C(blue)%s%C(red)%d' \
| fzf \
  --ansi \
  --reverse \
  --no-sort \
  --preview='
    echo {} | grep -o "[a-f0-9]\{7\}" \
    && git show --color $(echo {} | grep -o "[a-f0-9]\{7\}")
  '

The splendid result:

fzf interface to manage Git commits

We repeat echo {} | grep -o "[a-f0-9]\{7\}" two times here. The first one make sure that there is indeed a hash on the line (it’s possible to have lines without commit hashes because of the --graph option). If a hash is found on the selected line, we run git show with the hash as argument.

If there is also a hash in the subject of the commit, we end up with multiple hashes on the same line (as you can see above). Let’s make sure that we only give to git show the first hash of the line:

git log --graph --color \
  --format='%C(white)%h - %C(green)%cs - %C(blue)%s%C(red)%d' \
| fzf \
  --ansi \
  --reverse \
  --no-sort \
  --preview='
    echo {} | grep -o "[a-f0-9]\{7\}" \
    && git show --color $(echo {} \
    | grep -o "[a-f0-9]\{7\}" \
    | sed -n "1p")
  '

The preview command can be simpler (without using sed or even grep) if you get rid of the --graph option. As a result, you’re now sure that the commit is always the first field of each line:

git log --color \
  --format='%C(white)%h - %C(green)%cs - %C(blue)%s%C(red)%d' \
| fzf \
  --ansi \
  --reverse \
  --no-sort \
  --preview='git show --color {1}'

Scrolling and looking at fzf’s preview is not always the most practical. We could also try to open a new subshell and display the commit’s changes using less when hitting ENTER:

git log --graph --color \
  --format='%C(white)%h - %C(green)%cs - %C(blue)%s%C(red)%d' \
| fzf \
  --ansi \
  --reverse \
  --no-sort \
  --preview='
    hash=$(echo {} | grep -o "[a-f0-9]\{7\}" | sed -n "1p") \
    && [[ $hash != "" ]] \
    && git show --color $hash
    ' \
  --bind='enter:execute(
    hash=$(echo {} | grep -o "[a-f0-9]\{7\}" | sed -n "1p") \
    && [[ $hash != "" ]] \
    && sh -c "git show --color $hash | less -R"
    )' \
  --header-first \
  --header '
    > ENTER to display the diff
  '

Now that we can get the hash of each of our commits, let’s add more functionalities to our interface.

Checkout and Reset Commits

It’s sometimes useful to checkout a commit to look at the state of a project at a specific point in time. Let’s add a binding to do so:

git log --graph --color \
  --format='%C(white)%h - %C(green)%cs - %C(blue)%s%C(red)%d' \
| fzf \
  --ansi \
  --reverse \
  --no-sort \
  --preview='
    hash=$(echo {} | grep -o "[a-f0-9]\{7\}" | sed -n "1p") \
    && [[ $hash != "" ]] \
    && git show --color $hash
    ' \
  --bind='enter:execute(
    hash=$(echo {} | grep -o "[a-f0-9]\{7\}" | sed -n "1p") \
    && [[ $hash != "" ]] \
    && sh -c "git show --color $hash | less -R"
    )' \
  --bind='alt-c:execute(
    hash=$(echo {} | grep -o "[a-f0-9]\{7\}" | sed -n "1p") \
    && [[ $hash != "" ]] \
    && git checkout $hash
    )+abort' \
  --header-first \
  --header '
  > ENTER to display the diff
  > ALT-C to checkout the commit
  '

Also, when I develop a new functionality, I often create a bunch of random commits. When I’m done, I reset to the first commit I’ve made for this functionality, and I re-create a bunch of new commits which are more logical, describing each important step, and it’s ideally possible to revert them without crashing the whole application.

It’s quite trivial to add this functionality to our interface:

git log --graph --color \
  --format='%C(white)%h - %C(green)%cs - %C(blue)%s%C(red)%d' \
| fzf \
  --ansi \
  --reverse \
  --no-sort \
  --preview='
    hash=$(echo {} | grep -o "[a-f0-9]\{7\}" | sed -n "1p") \
    && git show --color $hash
    ' \
  --bind='enter:execute(
    hash=$(echo {} | grep -o "[a-f0-9]\{7\}" | sed -n "1p") \
    && [[ $hash != "" ]] \
    && sh -c "git show --color $hash | less -R"
    )' \
  --bind='alt-c:execute(
    hash=$(echo {} | grep -o "[a-f0-9]\{7\}" | sed -n "1p") \
    && [[ $hash != "" ]] \
    && git checkout $hash
    )+abort' \
  --bind='alt-r:execute(
    hash=$(echo {} | grep -o "[a-f0-9]\{7\}" | sed -n "1p") \
    && [[ $hash != "" ]] \
    && git reset $hash
    )+abort' \
  --header-first \
  --header '
  ENTER to display the diff
  ALT-C to checkout the commit | ALT-R to reset to the commit
  '

Interactive Rebasing

Interactive rebasing can be useful if you want to modify a bunch of commits. Let’s add the functionality in our interface:

git log --graph --color --format='%C(white)%h - %C(green)%cs - %C(blue)%s%C(red)%d' | fzf \
  --ansi \
  --reverse \
  --no-sort \
  --preview='
    hash=$(echo {} | grep -o "[a-f0-9]\{7\}" | sed -n "1p") \
    && [[ $hash != "" ]] \
    && git show --color $hash
    ' \
  --bind='enter:execute(
    hash=$(echo {} | grep -o "[a-f0-9]\{7\}" | sed -n "1p") \
    && [[ $hash != "" ]] \
    && sh -c "git show --color $hash | less -R"
    )' \
  --bind='alt-c:execute(
    hash=$(echo {} | grep -o "[a-f0-9]\{7\}" | sed -n "1p") \
    && [[ $hash != "" ]] \
    && git checkout $hash
    )+abort' \
  --bind='alt-r:execute(
    hash=$(echo {} | grep -o "[a-f0-9]\{7\}" | sed -n "1p") \
    && [[ $hash != "" ]] \
    && git reset $hash
    )+abort' \
  --bind='alt-i:execute(
    hash=$(echo {} | grep -o "[a-f0-9]\{7\}" | sed -n "1p") \
    && [[ $hash != "" ]] \
    && git rebase --interactive $hash
    )+abort' \
  --header-first \
  --header '
  > ENTER to display the diff
  > ALT-C to checkout the commit | ALT-R to reset to the commit
  > ALT-I to rebase interactively
  '

Nothing new here; as you can see, it’s trivial to implement new bindings using the commit hash of the current line.

Cherry-Pick a Commit

It can be useful to cherry-pick a commit and add it on top of the current branch. We could add the following to our previous command:

  --bind='alt-p:execute(
    hash=$(echo {} | grep -o "[a-f0-9]\{7\}" | sed -n "1p") \
    && [[ $hash != "" ]] \
    && git cherry-pick $hash
    )+abort' \

But it’s not idea: our interface only list commits of the current branch, and it’s often useful to cherry-pick a commit from another branch. Again, we could imagine switching between two lists in fzf:

  1. The list of the commits of the current branch.
  2. The list of all commits reachable from a reference (for example a branch or a tag).

To do so, we’ll have to use the fzf action transform again. Here’s a possible solution:

branch_commits='git log --graph --color --format="%C(white)%h - %C(green)%cs - %C(blue)%s%C(red)%d"' \
&& all_commits='git log --all --graph --color --format="%C(white)%h - %C(green)%cs - %C(blue)%s%C(red)%d"' \
&& eval "$branch_commits" | fzf \
  --ansi \
  --reverse \
  --no-sort \
  --prompt="Branch > " \
  --preview='
    hash=$(echo {} | grep -o "[a-f0-9]\{7\}" | sed -n "1p") \
    && [[ $hash != "" ]] \
    && git show --color $hash
    ' \
  --bind="ctrl-s:transform:[[ \$FZF_PROMPT =~ 'Branch >' ]] \
    && echo 'change-prompt(All > )+reload($all_commits)' \
    || echo 'change-prompt(Branch > )+reload($branch_commits)'" \
  --bind='enter:execute(
    hash=$(echo {} | grep -o "[a-f0-9]\{7\}" | sed -n "1p") \
    && [[ $hash != "" ]] \
    && sh -c "git show --color $hash | less -R"
    )' \
  --bind='alt-c:execute(
    hash=$(echo {} | grep -o "[a-f0-9]\{7\}" | sed -n "1p") \
    && [[ $hash != "" ]] \
    &&  git checkout $hash
    )+abort' \
  --bind='alt-r:execute(
    hash=$(echo {} | grep -o "[a-f0-9]\{7\}" | sed -n "1p") \
    && [[ $hash != "" ]] \
    && git reset $hash
    )+abort' \
  --bind='alt-i:execute(
    hash=$(echo {} | grep -o "[a-f0-9]\{7\}" | sed -n "1p") \
    && [[ $hash != "" ]] \
    && git rebase --interactive $hash
    )+abort' \
  --bind='alt-p:execute(
    hash=$(echo {} | grep -o "[a-f0-9]\{7\}" | sed -n "1p") \
    && [[ $hash != "" ]] \
    && git cherry-pick $hash
    )+abort' \
  --header-first \
  --header '
  > ENTER to display the diff
  > ALT-C to checkout the commit | ALT-R to reset to the commit
  > ALT-I to rebase interactively
  > ALT-P to cherry pick
  '

We’ve created a monster again; it’s time to create a Bash script and refactor this mess. You’ll find the complete function here.

We have now two different interfaces to manage our files and our commits. Again, it’s time to move a level higher: let’s create a last interface to manage our Git branches.

Working with Branches

First, let’s list all the branches we have for our current project. We can do so with this simple command:

git branch --color

The output:

  another_branch
* main
  new-branch
  yet_another_branch

If we want to also include the remote branches in the list, we can add the option --all:

git branch --all --color

What about checkout the selected branch directly from fzf? We can do that easily with the following:

git checkout \
  $(git branch --color | fzf --ansi --reverse --no-sort | tr -d ' ')

Since the output of git branch includes many SPACE characters, we use here the CLI tool tr to delete them.

We can also shorten the reference or our remote branch by using the --format option:

git branch --all --color --format="%(refname:short)"

The output:

another_branch
main
new-branch
yet_another_branch
origin
origin/another_branch
origin/main
origin/yet_another_branch

We can also add some color:

git branch --all --color --format="%(color:green) %(refname:short)"

It would also be nice to have a star * in front of the current branch, to know where we are in life:

git branch --all --color --format="%(HEAD) %(color:green)%(refname:short)"

If we want to checkout the selected branch, we also need to get rid of the star *:

git checkout $(git branch --all --color \
  --format="%(HEAD) %(color:green)%(refname:short)" \
| fzf --ansi --reverse --no-sort \
| sed 's/^[* ]*//')

Granted, trying to checkout the current branch is a bit useless, but, at least, with this solution, Git will output the correct error message if we try to do so.

Let’s not stop here: let’s add the short committer date, and the subject of the last commit of each branch since we’re at it:

git checkout $(git branch --all --color \
  --format="%(HEAD) %(color:yellow)%(refname:short) %(color:green)%(committerdate:short) %(color:blue)%(subject)" \
| fzf --ansi --reverse --no-sort \
| sed 's/^[* ]*//' \
| awk '{print $1}')

We also have more information per line now, so we need to use the CLI tool awk again to select the name of the branch we want to checkout.

We’re using the short version of the committer date (for example 2024-01-01), but you can also use the relative committer date (for example 2 weeks ago) with committerdate:relative.

As you can see, the formatting is a bit all over the place. We could try to format our output in a table, using the CLI tool column:

git checkout $(git branch --all --color \
  --format=$'%(HEAD) %(color:yellow)%(refname:short)\t%(color:green)%(committerdate:short)\t%(color:blue)%(subject)' \
| column --table --separator $'\t' \
| fzf --ansi --reverse --no-sort \
| sed 's/^[* ]*//' \
| awk '{print $1}')

We use the quoting $' here to interpret the escape sequence \t as a TAB character. We could have used any other character as delimiter for our columns, but if this delimiter is also in the subject of the last commit of a branch, the formatting breaks. Feel free to use any other delimiter if you have some TAB characters in one of these subjects.

Let’s now display the logs of the selected branch as preview:

git checkout $(git branch --all --color \
  --format=$'%(HEAD) %(color:yellow)%(refname:short)\t%(color:green)%(committerdate:short)\t%(color:blue)%(subject)' \
| column --table --separator $'\t' | \
  fzf \
  --ansi \
  --reverse \
  --no-sort \
  --preview 'git log $(echo {} \
    | sed "s/^[* ]*//" \
    | awk "{print \$1}") \
    --graph --color \
    --format="%C(white)%h - %C(green)%cs - %C(blue)%s%C(red)%d"' \
| sed 's/^[* ]*//' \
| awk '{print $1}')

We could also display the diff between the current branch and the branch selected in fzf as preview. Let’s also bind the ENTER key to git checkout, to avoid closing fzf when we checkout a different branch:

git_branches="git branch --all --color \
  --format=$'%(HEAD) %(color:yellow)%(refname:short)\t%(color:green)%(committerdate:short)\t%(color:blue)%(subject)' \
  | column --table --separator=$'\t'" \
&& eval "$git_branches" \
| fzf \
  --ansi \
  --reverse \
  --no-sort \
  --preview-label '[ Commits ]' \
  --preview 'git log $(echo {} \
    | sed "s/^[* ]*//" | \
    awk "{print \$1}") \
    --graph --color  \
    --format="%C(white)%h - %C(green)%cs - %C(blue)%s%C(red)%d"' \
  --bind 'ctrl-f:change-preview-label([ Diff ])' \
  --bind 'ctrl-f:+change-preview(
    git diff --color \
    $(git branch --show-current)..$(echo {} \
      | sed "s/^[* ]*//" \
      | awk "{print \$1}")
    )' \
  --bind 'ctrl-i:change-preview-label([ Commits ])' \
  --bind 'ctrl-i:+change-preview(
    git log $(echo {} \
    | sed "s/^[* ]*//" \
    | awk "{print \$1}") \
    --graph --color \
    --format="%C(white)%h - %C(green)%cs - %C(blue)%s%C(red)%d")' \
  --bind 'enter:execute(
    git checkout $(echo {} \
    | sed "s/^[* ]*//" \
    | awk "{print \$1}")
    )' \
  --bind "enter:+reload($git_branches)" \
  --header-first \
  --header '
  > CTRL-F to preview with diff | CTRL-I to preview with logs
  > ENTER to checkout the branch
  '

The fantastic result:

fzf interface to manage Git branches

We could also display the diff in a subshell when hitting ENTER, and checkout any branch with ALT-c:

git_branches="git branch --all --color \
  --format=$'%(HEAD) %(color:yellow)%(refname:short)\t%(color:green)%(committerdate:short)\t%(color:blue)%(subject)' \
  | column --table --separator=$'\t'" \
&& eval "$git_branches" \
| fzf \
  --ansi \
  --reverse \
  --no-sort \
  --preview-label='[ Commits ]' \
  --preview='
    git log $(echo {} \
    | sed "s/^[* ]*//" \
    | awk "{print \$1}") \
    --graph --color \
    --format="%C(white)%h - %C(green)%cs - %C(blue)%s%C(red)%d"' \
  --bind='alt-c:execute(
    git checkout $(echo {} \
    | sed "s/^[* ]*//" \
    | awk "{print \$1}")
    )' \
  --bind "alt-c:+reload($git_branches)" \
  --bind='enter:execute(
    branch=$(echo {} \
    | sed "s/^[* ]*//" \
    | awk "{print \$1}") \
    && sh -c "git diff --color $branch \
    | less -R"
    )' \
  --header-first \
  --header '
  > ALT C to checkout the branch
  > ENTER to open the diff with less
  '

Let’s not stop here: what about merging and rebasing the branch selected with the current one?

git_branches="git branch --all --color \
  --format=$'%(HEAD) %(color:yellow)%(refname:short)\t%(color:green)%(committerdate:short)\t%(color:blue)%(subject)' \
  | column --table --separator=$'\t'" \
&& eval "$git_branches" \
| fzf \
  --ansi \
  --reverse \
  --no-sort \
  --preview-label='[ Commits ]' \
  --preview='git log $(echo {} \
    | sed "s/^[* ]*//" \
    | awk "{print \$1}") \
    --graph --color \
    --format="%C(white)%h - %C(green)%cs - %C(blue)%s%C(red)%d"' \
  --bind='alt-c:execute(
    git checkout $(echo {} \
    | sed "s/^[* ]*//" \
    | awk "{print \$1}")
    )' \
  --bind="alt-c:+reload($git_branches)" \
  --bind='alt-m:execute(git merge $(echo {} \
    | sed "s/^[* ]*//" \
    | awk "{print \$1}")
    )+abort' \
  --bind='alt-r:execute(git rebase $(echo {} \
    | sed "s/^[* ]*//" \
    | awk "{print \$1}")
    )+abort' \
  --bind='enter:execute(
    branch=$(echo {} \
    | sed "s/^[* ]*//" \
    | awk "{print \$1}") \
    && sh -c "git diff --color $branch | less -R"
    )' \
  --header-first \
  --header '
  > The branch marked with a star * is the current branch
  > ALT-C to checkout the branch
  > ALT-M to merge with current branch | ALT-R to rebase with current branch
  > ENTER to open the diff with less
  '

You have now a good foundation to add anything you want. You could add a binding to run git fetch to get all the remote branches for example, or add another binding running git branch --delete --force to delete a remote branch. The sky’s the limit.

As always, there is a final Bash script waiting for you.

Vim Integration

What about having these nice interfaces directly in Vim? I already cover fzf integration with Vim in this article.

For example, we could create a new scrim fzf.vim and source it in our vimrc. Here’s a simple fzf interface to stage files:

let s:git_unstaged='git ls-files --modified --deleted --other --exclude-standard --deduplicate $(git rev-parse --show-toplevel)'
command! -bang GitAdd call fzf#run(fzf#wrap({
    \ 'source': s:git_unstaged,
    \ 'options': [
        \ '--multi',
        \ '--reverse',
        \ '--no-sort',
        \ '--prompt', 'Add > ',
        \ '--preview', 'git status --short',
        \ '--bind', 'enter:execute(git add {+})',
        \ '--bind', 'enter:+reload('.s:git_unstaged.')',
    \ ]}))

The prefix s: in Vimscript simply means that the scope of the variable is the current script only, to avoid conflicts with other variables having the same name.

An Interface to Merge Them All

As we saw in this article, using the advanced functionalities of fzf is not hard if we build our interfaces step by step. We can then craft powerful and customized tools directly in our comfy shell, without the need to program a full-blown TUI.

If you have other ideas to improve the functions we’ve written in this article, or if you want to share your own interfaces written with fzf, don’t hesitate to leave a comment. You know, sharing is caring!

Share Your Knowledge