March 30, 2021

Improving shell workflows with fzf

Working in a shell usually involves executing the same set of commands again and again; what changes is the order in which the commands are executed, and the parameters that are passed to the command. One way to improve shell workflows is finding patterns in the execution order of commands and extracting those into little scripts; this can often be helpful and is part of what makes working in a shell so powerful. Another way is to understand how the parameters are added and try to simplify this; that’s what I will focus mostly on in this blog post.

Typical parameters in my workflows are file names or git branch names (looking at my history, git is by far my most used command). Typing them in manually is often cumbersome and error-prone, and that’s why I avoid doing it whenever possible. Depending on the command there might be tab completion which can help a lot, but it is not always the most convenient. In this blog post I will show how to use fzf as an alternative.

The basic functionality of fzf is very simple: it reads a set of lines from stdin, provides a user interface to select one or more lines, and writes the selected lines to stdout. This sounds very basic, but it is in fact very powerful. The fzf wiki has a ton of examples on how this functionality can be used effectively. This is a great resource, and I have taken several of the functions shown there into my repertoire and use them almost daily. But in your own shell usage, you will often discover workflows that are fairly specific to your use cases; they are not general enough to be found on a wiki like this, but it is still useful to automate them for your own usage.

To show how I usually approach this, I am going to describe four shell workflows that I used to follow, and then show how to write a shell function with fzf that makes the workflow more convenient. They are (in increasing complexity):

For most of them, I will show a simple function that covers most use cases, and then extend this function with more functionality that makes it even more convenient or robust.

The final functions can also be found on GitHub, including variants for fish.

Activating python virtual environments

I keep my python virtual environments in a central place in ~/.venv. To activate one of the environments, I used to

This workflow can be improved by using a tool like virtualenvwrapper, but it is also a good example of a workflow that can be easily improved with fzf. The easiest solution can be just one line.

function activate-venv() {
  source "$HOME/.venv/$(ls ~/.venv/ | fzf)/bin/activate"
}
activate-venv-simple.bash(download)

We can activate this function with

source activate-venv-simple.bash

(add this to your .bashrc to make it permanent) and then use it as follows

A fzf flow showing multiple virtual environments in the selection window, and when a line is selected the virtual environment is activated.

Activating a virtual environment with fzf

A minor problem with the script is that if you exit out of fzf by pressing Ctrl-D, the script will fail with

bash: /home/crepels/.venv//bin/activate: No such file or directory

You can choose to ignore this since it has the desired effect: no virtual environment is activated. But a fix is also straightforward: we can store the result of fzf in a variable and only try to activate the virtual environment if the variable is non-empty.

function activate-venv() {
  local selected_env
  selected_env=$(ls ~/.venv/ | fzf)

  if [ -n "$selected_env" ]; then
    source "$HOME/.venv/$selected_env/bin/activate"
  fi
}
activate-venv.bash(download)

Deleting git branches

Another pattern that I found myself repeating was during clean up of my git branches. For every feature or experiment I create a new branch in git. Once the feature is merged to the main branch or the experiment is no longer needed, the branch can be deleted. But I usually don’t delete the branch immediately. Instead, the branches pile up until it reaches a point where it makes navigating the branches with active code harder. When I actually come around to clean up the branches, the workflow looks something like this.

Sometimes I’m not sure whether the branch can really be deleted, so I first need to run git log on it to see what it actually contains, and then delete the branch as above. I then repeat this process until all stale branches are deleted.

This workflow is another good example which can be made a lot easier with fzf. This time we pass the --multi option to fzf, which allows selecting multiple entries with <tab>.

function delete-branches() {
  local branches_to_delete
  branches_to_delete=$(git branch | fzf --multi)

  if [ -n "$branches_to_delete" ]; then 
    git branch --delete --force $branches_to_delete
  fi
}
delete-branches-simple.bash(download)

After executing source delete-branches-simple.bash we can use it as follows.

A fzf flow which shows a list of branches, lets the user select multiple branches, and then deletes them.

Deleting branches with fzf

This version mostly works, but there are a few improvements that we can do. First, git branch shows all branches, including the one that is currently checked out, marked with a *. Since you cannot delete the branch that is currently checked out, it doesn’t make sense to show it in the first place. We can omit it by piping the output of git branch through grep --invert-match '\*'.

Another issue is that we pass $branches_to_delete unquoted to git branch -D; we need to do this because git needs to receive every branch as a separate argument. If you use a linter like shellcheck, it will complain about this line, since unquoted variables can cause globbing and word splitting. In our case this is a false alarm since branch names cannot contain globbing characters; nevertheless, I think it’s a good practice to avoid unquoted variables whenever possible. One way to do this is to pipe the output of fzf through xargs directly into git branch -D instead of storing it in a variable. If we add the --no-run-if-empty option to xargs, it will only call git if there was at least one branch selected.

Finally, I mentioned that it might be useful to see the output of git log for the selected branch. We can do so with the --preview option of fzf. The value of --preview can be any command; it will be executed whenever a new line is selected in fzf, and the output is shown in a preview window. In the command, {} acts as a placeholder that is replaced with the currently selected line. In our case, we will use git log {} -- as the preview command; the -- prevents errors if there is a file with the same name as the branch (thanks to Jens Pfeifle for pointing this out).

function delete-branches() {
  git branch |
    grep --invert-match '\*' |
    cut -c 3- |
    fzf --multi --preview="git log {} --" |
    xargs --no-run-if-empty git branch --delete --force
}
delete-branches.bash(download)

Note that we also pipe the output of git branch through cut -c 3-, which removes the first two characters from every line. If you look at the output of git branch, you’ll see that every branch except the current one is prefixed by two spaces. If we would not remove them with cut, the preview command would be executed as git log '  branch-name' --, which causes git to complain because of the leading whitespace. (An alternative would be to use git log {..} -- as a preview command, which will strip the whitespace from the selected line.)

Here is an example where we delete the same three branches as above, but we get more information while we do so.

A fzf flow to delete branches, showing the branches in the selection window and the output of git log in the preview window.

Deleting branches with fzf — improved version

Checking out pull requests locally

For code reviews, I often find it helpful to check out the code under review. The GitHub CLI makes this easy: if you execute gh pr checkout <pr-number> in a git repository, it will check out the pull request with number <pr-number> to a local branch. But where do we get <pr-number> from? In my workflow, I would

This works well for one or two-digit numbers, but even for three-digit numbers I sometimes have to switch back to the browser to make sure that I remembered the number correctly.

In my last blog post I already described how to query the GitHub API with gh; we can use this here to get <pr-number> automatically. Specifically, we can get the open pull requests from the GitHub API with

gh api 'repos/:owner/:repo/pulls'

It returns an array of JSON objects, one for each pull request. We need to convert this array of JSON objects to a format that fzf can interpret: a separate row for every pull request. If we think about the data that we need, the first thing is the pull request number that we want to pass to gh checkout. We also need some way to identify the pull request that we are interested, and the title is the most obvious candidate. We can use jq’s string interpolation to extract this information into a JSON string.

gh api 'repos/:owner/:repo/pulls' |
    jq --raw-output '.[] | "#\(.number) - \(.title)"'

(The --raw-output string evaluates the JSON string; without it, every row would be surrounded by quote characters.) For example, if I execute this in a directory with a checkout of https://github.com/junegunn/fzf, it outputs

#2368 - ansi: speed up parsing by roughly 7.5x
#2349 - Vim plugin fix for Cygwin 3.1.7 and above
#2348 - [completion] Default behaviour to use fd if present else use find.
#2302 - Leading double-quote for exact match + case sensitive search
#2197 - Action accept-1 to accept a single match
#2183 - Fix quality issues
#2172 - Draft: Introduce --print-selected-count
#2131 - #2130 allow sudo -E env fzf completion
#2112 - Add arglist support to fzf.vim
#2107 - Add instructions on command for installing fzf with Guix and/or Guix System
#2077 - Use fzf-redraw-prompt in history widget
#2004 - Milis Linux support
#1964 - Use tmux shell-command
#1900 - Prompt generally signals that the shell is ready
#1867 - add {r}aw flag to disable quoting in templates
#1802 - [zsh completion] Expand aliases recursively
#1705 - Option to select line index of input feed and to output cursor line index
#1667 - $(...) calls should be quoted: \"$(...)\"
#1664 - Add information about installing using Vundle
#1616 - Use the vim-specific shell instead of the environment variable
#1581 - add pre / post completion 'hooks'
#1439 - Suppress the zsh autocomplete line number output
#1299 - zsh completion: Add support for per-command completion triggers.
#1245 - Respect switchbuf option
#1177 - [zsh] let key bindings be customized through zstyle
#1154 - Improve kill completion.
#1115 - _fzf_complete_ssh: support Include in ssh configs
#559 - [vim] use a window-local variable to find the previous window
#489 - Bash: Key bindings fixes

If we pipe this into fzf it will allow us to select any of those lines and write it to stdout. We are only interested in the number, so we extract it using sed and a regex with capture group. Our first version of a working script looks like this.

function pr-checkout() {
  local pr_number

  pr_number=$(
    gh api 'repos/:owner/:repo/pulls' |
    jq --raw-output '.[] | "#\(.number) \(.title)"' |
    fzf |
    sed 's/^#\([0-9]\+\).*/\1/'
  )

  if [ -n "$pr_number" ]; then
    gh pr checkout "$pr_number"
  fi
}
pr-checkout-simple.bash(download)

Let’s try it out on the fzf repository.

A fzf flow which shows the pull request titles in the selection window; when a row is selected, the pull request is shown to be checked out locally.

Checking out a pull request with fzf

This is probably enough for most use cases (and the GitHub blog has an even simpler script which uses the output of gh directly). But maybe the title is not be enough to determine the pull request that we are interested in; more information might help. For example, we could show the description of the pull request in the preview window, or other information that we can get from the API.

In the function to delete branches above, we populated the preview window by calling git log on the currently selected branch. A first idea would be to try something similar here and query the API for the currently selected pull request. But querying the API whenever we select a different line would create annoying delays and would make it hard to use. Luckily, we don’t need to query the API again. We already have all the data we need from the first call to the API. What we can do is to extend our jq template string to extract all the information that we care about, and then use a feature from fzf that allows us to hide some information from the input lines in the selection window and show it in the preview.

fzf considers each line as an array of fields. By default, fields are separated by whitespace sequences (tabs and spaces), but we can control the separator with the --delimiter option. For example, if we set --delimiter=',' and pass the row first,second,third to fzf, then the fields are first,, second,, and third. By itself, this is not useful yet. But using the --with-nth option, we can control the fields that are displayed in the selection window. For example, fzf --with-nth=1,2 will only display the first and second field of each row. Furthermore, we saw above that we can write {} as a placeholder in the preview command and fzf will replace it with the currently selected row. But {} is just the simplest form of a placeholder. We can also specify field indices within the braces and fzf will replace the placeholder with those fields.

Here is an example where we use both --with-nth and --preview, using <tab> as a delimiter.

echo -e 'first line\tfirst preview\nsecond line\tsecond preview' |
    fzf --delimiter='\t' --with-nth=1 --preview='echo {2}'

fzf will split each line at a tab character; the --with-nth=1 option instructs fzf to show the first part in the selection window; the {2} in the preview command will be replaced with the second part, and since it is passed to echo, it will just be displayed.

Executing the example code, showing that fzf displays the first part of a row in the selection window and the second part in the preview window.

An example of using fields in fzf

We will use this to show some useful information in the preview window. Let’s first look at the final script, and then we’ll go through it step by step.

function pr-checkout() {
  local jq_template pr_number

  jq_template='"'\
'#\(.number) - \(.title)'\
'\t'\
'Author: \(.user.login)\n'\
'Created: \(.created_at)\n'\
'Updated: \(.updated_at)\n\n'\
'\(.body)'\
'"'

  pr_number=$(
    gh api 'repos/:owner/:repo/pulls' |
    jq ".[] | $jq_template" |
    sed -e 's/"\(.*\)"/\1/' -e 's/\\t/\t/' |
    fzf \
      --with-nth=1 \
      --delimiter='\t' \
      --preview='echo -e {2}' \
      --preview-window=top:wrap |
    sed 's/^#\([0-9]\+\).*/\1/'
  )

  if [ -n "$pr_number" ]; then
    gh pr checkout "$pr_number"
  fi
}
pr-checkout.bash(download)

There are some changes to the simple function. We extract the jq template string into a variable and extend it with more information: the author, the timestamps when the pull request was created and last updated, and the description of the pull request. All of this information is contained in the JSON returned by the GitHub API when we call gh api 'repos/:owner/:repo/pulls'.

Notice that we separated the new information from the number and title by a tab character \t. We use this also as a delimiter for fzf, and then show the pull request number and title in the selection window (using --with-nth=1) and the rest in the preview window (using --preview='echo -e {2}').

Notice also that this time we don’t use the --raw-output option for jq. The reason is a bit subtle. The strings that we create with jq contain escaped newline characters. If we pass the --raw-output option to jq, it will interpret all escaped characters; in particular, it will write a literal new line character for any \n in the string. For example, compare the output of

echo '{}' | jq --raw-output '"first\nsecond"'

to the output of

echo '{}' | jq '"first\nsecond"'

The first outputs

first
second

while the second outputs

"first\nsecond"

The first version is problematic. Remember that fzf is a row-based program. It takes a list of rows, allows the user to select one or more of them, and outputs the selected rows. This means that with the --raw-output option, every pull request would show up as multiple rows in fzf; this is clearly not what we want. So instead, we let jq output the escaped version to ensure that every pull request corresponds to a single row.

This however introduces new problems. First, we still want the preview window to show literal new line characters, not \n though; we solve this by using echo -e as a preview command (the -e enables interpretation of the escaped characters). The second problem is that without the --raw-output option, jq will show the quote characters at the start end the end of the string, and it will print our delimiter \t as an escaped character. We solve this by removing the quotes manually, and by replacing the first escaped tab character by an actual tab character; that’s what the sed is doing after the jq.

Finally, notice that we specify --preview-window=top:wrap so that fzf wraps lines in the preview window and displays it at the top of the screen instead of the right.

And here is how it looks like in action:

A fzf flow which shows the pull request titles in the selection window and details about the selected pull request in the preview window.

Checking out a pull request with fzf — preview version

Creating feature branches from JIRA issues

We saw above how we can use fzf do delete git branches. Now let’s look at the opposite: creating new branches. At my workplace, we are using JIRA for issue tracking. Every feature branch usually corresponds to some JIRA issue. To keep track of this correspondence, I use the following naming scheme for my git branches. Let’s say that the JIRA project is named BLOG, and I’m currently working on the issue BLOG-1232 with title “Add a verbose flag to the startup script”. Then I will name my branch BLOG-1232/add-a-verbose-flag-to-the-startup-script; the description part usually gives enough information to determine the feature that the branch corresponds to, and the BLOG-1232 part lets me jump to the JIRA ticket if I need to look up more details about the issue.

You might imagine how the workflow for creating these branches looks like:

Usually, I have to switch between browser and terminal several times, and I still manage to create typos in the branch name.

This is another workflow can be entirely automated. As with the GitHub pull requests, we can get the issues from the JIRA API. The function that we’ll create is similar to pr-checkout, but there are a few notable differences.

First, there is no convenient CLI tool like gh to communicate with the JIRA API. We have to talk to it directly using curl. Second, at least the JIRA server I am using doesn’t allow the creation of access tokens, which forces us to use basic authentication with username and password when talking to the API. I don’t want to store my password in a shell script, or in fact in any unencrypted file, so we’ll be using secret-tool for a more secure password storage. And finally, the creation of the branch name requires more logic than a simple text extraction; we’ll use a combination of cut, sed, and awk.

Let’s again first look at the final script and then try to understand how it works.

function create-branch() {
  # The function expectes that username and password are stored using secret-tool.
  # To store these, use
  # secret-tool store --label="JIRA username" jira username
  # secret-tool store --label="JIRA password" jira password

  local jq_template query username password branch_name

  jq_template='"'\
'\(.key). \(.fields.summary)'\
'\t'\
'Reporter: \(.fields.reporter.displayName)\n'\
'Created: \(.fields.created)\n'\
'Updated: \(.fields.updated)\n\n'\
'\(.fields.description)'\
'"'
  query='project=BLOG AND status="In Progress" AND assignee=currentUser()'
  username=$(secret-tool lookup jira username)
  password=$(secret-tool lookup jira password)

  branch_name=$(
    curl \
      --data-urlencode "jql=$query" \
      --get \
      --user "$username:$password" \
      --silent \
      --compressed \
      'https://jira.example.com/rest/api/2/search' |
    jq ".issues[] | $jq_template" |
    sed -e 's/"\(.*\)"/\1/' -e 's/\\t/\t/' |
    fzf \
      --with-nth=1 \
      --delimiter='\t' \
      --preview='echo -e {2}' \
      --preview-window=top:wrap |
    cut -f1 |
    sed -e 's/\. /\t/' -e 's/[^a-zA-Z0-9\t]/-/g' |
    awk '{printf "%s/%s", $1, tolower($2)}'
  )

  if [ -n "$branch_name" ]; then
    git checkout -b "$branch_name"
  fi
}
create-branch.bash(download)

We can see three parts. First, the curl command and its variables to talk to the JIRA API. Then the conversion of the API output into rows that are read by fzf; this part is the same as in pr-checkout. And finally converting the output of fzf into our desired format for the branch name.

The biggest change compared to pr-checkout is the curl command. We are using the JIRA search endpoint which expects a jql (JIRA query language) string as a URL parameter. In my case, I’m interested in all issues of the BLOG project which are assigned to me, and which are marked as In Progress. The jql string contains spaces, equal signs and parenthesis, all of which are not allowed in a URL, so they need to be encoded. curl can do this automatically with the --data-urlencode option; since this option uses a POST request by default we need to add the --get option to switch it back to a GET request. We also use the --user option which instructs curl to add the basic authentication header. Finally, we add the --silent option to omit any progress information and the --compressed option to save some bandwidth.

We are then using the same technique as above to convert every array entry in the JSON response into a single row, separating the search string and the preview by a tab character and passing it to fzf to allow the user to select an entry. The output of fzf will be a row like BLOG-1232. Add a verbose flag to the startup script<tab>{...preview part}. We use cut to strip away the preview part of the row (by default, cut uses <tab> as a delimiter, and -f1 instructs it to output the first field), resulting in BLOG-1232. Add a verbose flag to the startup script. Then sed replaces the first .  by a tab character, and replaces any non-alphanumeric character by a - (but preserving our newly introduced tab character) which yields BLOG-1232<tab>Add-a-verbose-flag-to-the-startup-script. Finally, awk takes the string, splits it at the tab character, converts the second part to lower case and puts both parts back together with / as a separator.

A fzf flow which shows the JIRA issue titles in the selection window and details about the issue in the preview window. When an issue is selected, a new branch is shown to be created.

Creating a branch from a JIRA issue

Conclusion

I presented four of my typical shell workflows and showed how they can be simplified with fzf. The resulting functions range from a simple one-liner to more complex functions with API calls and non-trivial logic, but all of them have in common that they reduce a workflow of several steps to a single command without any parameters.

The exact workflows that I presented might not be relevant to you. But hopefully the general technique might be: try to observe how you add parameters to commands and see if this can be automated. The parameters could be files in a fixed location (like the virtual environments), or they could be parameters that you can get through another command (like git branches) or through an API (like the pull request number or the JIRA title).

—Written by Sebastian Jambor. Follow me on Mastodon @crepels@mastodon.social for updates on new blog posts.