Home/Guides/fzf Project Switcher

Guide

Build an fzf Project Switcher (and When to Use son Instead)

fzf is one of the most powerful tools in a developer's terminal toolkit. In this guide, we will build a project picker from scratch, progressively adding features until we hit the limits of what a shell script can comfortably handle.

What Is fzf?

fzf (short for fuzzy finder) is a general-purpose command-line fuzzy finder written in Go. You pipe a list of items into it, and it gives you an interactive search interface where you can type fragments of what you are looking for and see matches instantly.

# Install fzf
$ brew install fzf # macOS
$ sudo apt install fzf # Ubuntu/Debian
# Basic usage: search files
$ ls | fzf
 
# Search anything: processes, git branches, commands
$ git branch | fzf
$ history | fzf

fzf is a building block, not a solution. It knows nothing about projects, git repos, or terminal workspaces. That is your job. Let us build something with it.

Version 1: Basic Project Picker (5 Lines)

The simplest fzf project picker finds all git repos under a directory and lets you pick one:

# Add to ~/.bashrc or ~/.zshrc
proj() {
local dir
dir=$(find ~/dev -maxdepth 3 -name ".git" -type d \
| sed 's|/\.git$||' \
| sed "s|$HOME/dev/||" \
| fzf --height 40% --reverse --prompt="project> ")
[[ -n "$dir" ]] && cd "$HOME/dev/$dir"
}
# Usage
$ proj
project> api
mycompany/api-gateway
personal/api-client
oss/openapi-gen

This works. It is simple and fast enough for small project collections. But it has problems: it rescans the filesystem every time, the results are unsorted (alphabetical at best), and all you get is a cd into the directory.

Version 2: Adding Git Timestamps (15 Lines)

Let us make the list more useful by showing the last commit date, so you can see which projects are active:

proj() {
local selected
selected=$(
find ~/dev -maxdepth 3 -name ".git" -type d 2>/dev/null \
| sed 's|/\.git$||' \
| while read -r repo; do
ts=$(git -C "$repo" log -1 --format='%ci' 2>/dev/null \
| cut -d' ' -f1 || echo "no-commits")
name=$(echo "$repo" | sed "s|$HOME/dev/||")
printf "%-40s %s\n" "$name" "$ts"
done \
| sort -k2 -r \
| fzf --height 50% --reverse --prompt="project> " \
| awk '{print $1}'
)
[[ -n "$selected" ]] && cd "$HOME/dev/$selected"
}
# Output now shows dates
project>
mycompany/api-gateway 2026-04-07
mycompany/frontend 2026-04-06
personal/blog 2026-04-05
oss/old-experiment 2025-11-12

Better. Active projects now appear at the top. But there is a noticeable lag: the script runs git log inside every repo sequentially. With 50 repos, you might wait 2-3 seconds. With 200 repos, it could take 10+ seconds. You can optimize this with parallel execution, but the script is getting complex.

Version 3: Adding Frecency (40+ Lines)

Git timestamps tell you when code was last committed, but not when you last worked on a project. You might access a repo for code review without committing. To track actual usage, you need a frequency/recency database:

# Frecency tracking file
PROJ_DB="$HOME/.proj_frecency"
 
_proj_record() {
local proj="$1" now=$(date +%s)
touch "$PROJ_DB"
echo "$now $proj" >> "$PROJ_DB"
}
 
_proj_score() {
local now=$(date +%s)
awk -v now="$now" '{
age = (now - $1) / 3600
if (age < 1) w = 8
else if (age < 24) w = 4
else if (age < 168) w = 2
else w = 1
scores[$2] += w
} END {
for (p in scores) printf "%d %s\n", scores[p], p
}' "$PROJ_DB" | sort -rn
}
 
proj() {
local selected
selected=$(
{ _proj_score; find ~/dev -maxdepth 3 -name ".git" -type d \
| sed 's|/\.git$||;s|.*/dev/||' \
| awk '{print "0 " $0}'; } \
| sort -k2 -u | sort -k1 -rn \
| awk '{print $2}' \
| fzf --height 50% --reverse --prompt="project> "
)
if [[ -n "$selected" ]]; then
_proj_record "$selected"
cd "$HOME/dev/$selected"
fi
}

Now we have a proper frecency-sorted project picker. Projects you use frequently and recently appear at the top. It works. But look at how much code this is, and it is still just a directory jumper.

Where the DIY Script Hits Its Limits

Even with Version 3, there are several things your script cannot do without becoming a full application:

  • 1.No workspace launch. The script gets you into a directory. It does not open your editor, start dev servers, or split your terminal into panes. Adding that means shelling out to tmux, iTerm2 AppleScript, or WezTerm CLI -- each with its own API.
  • 2.No multi-terminal support. Writing code that works in tmux, iTerm2, and WezTerm requires handling three different APIs. That is a lot of bash.
  • 3.No per-project config. Different projects need different layouts. Your API needs a 3-pane setup; your blog needs a single pane. Encoding this in bash gets ugly fast.
  • 4.Caching is manual. The find+git scan is slow. You could cache results to a file, but then you need cache invalidation logic, background refresh, etc.
  • 5.Maintenance burden. Shell scripts are fragile. Edge cases accumulate: repos with spaces in paths, repos without commits, submodules, nested git directories. Each is a bug waiting to happen.

At some point, you cross the line from "handy shell function" to "application I need to maintain." That is the point where a dedicated tool pays for itself.

When to Use son Instead

son is essentially what Version 3 of the script above would become if you kept adding features and rewrote it in a compiled language. It handles all the things the DIY script cannot:

FeatureDIY fzf Scriptson
Fuzzy project searchYesYes
Frecency sorting40+ lines of awkBuilt-in
Cached project indexDIY or slowAutomatic
Split pane workspaceNoYes
Multi-terminal (tmux/iTerm/WezTerm)Pick one or write 3x codeAll supported
Per-project configNo.son.toml
Editor integrationManualBuilt-in
Startup hooksNoYes
Single binary, no depsNeeds bash + fzf + gitYes (fzf included)

That said, keep the DIY script if:

  • +You have fewer than 10 projects and do not need workspace automation.
  • +You enjoy customizing shell scripts and want full control.
  • +You only need to cd into a directory and nothing else.

Getting Started with son

If you are ready to move beyond the script, here is the setup:

# Install
$ brew install abdussamet032/tap/son
# Run it
$ son
# Bind to a key (optional)
$ echo 'bindkey -s "^P" "son\n"' >> ~/.zshrc

No config files. No database setup. No dependencies to install. It discovers your projects automatically from common dev directories, and the frecency ranking starts learning your patterns from the first use.

For more on workspace layouts, see the tmux split panes guide. For tips on managing large numbers of repos, see managing multiple git repos.

From 40 lines of bash to one binary.

Keep your fzf skills. Lose the maintenance burden.

$ brew install abdussamet032/tap/son