Skip to main content
Settings
Search
Appearance
Theme Mode
About
Jekyll v3.10.0
Environment Production
Last Build
2026-06-26 19:04 UTC
Current Environment Production
Build Time Jun 26, 19:04
Jekyll v3.10.0
Build env (JEKYLL_ENV) production
Quick Links
Page Location
Page Info
Layout article
Collection hacks
Path _hacks/fzf-multi-select-functions.md
URL /hacks/fzf-multi-select-functions/
Date 2026-06-25
Theme Skin
SVG Backgrounds
Layer Opacity
0.6
0.04
0.08

fzf -m: the multi-select functions, and the one time you don't quote

Last time we built four single-pick fzf functions and ended with a one-line warning: the instinct to quote every fzf result has exactly one exception, fzf -m, and we’d come back to it. This is coming back to it.

Add -m (multi-select) and the picker grows checkboxes: TAB toggles a line, Shift-TAB toggles back, Enter returns every line you marked — one per output line. Three things you suddenly want to do to a handful of items: kill several processes, stage several files, delete several branches. Each one ran for real below, and the whole post turns on a single character: the quote you deliberately leave off.

How these were captured

fzf -m is interactive — you TAB through a full-screen list. A web page can’t show you tabbing, so the pick is stood in by fzf -m --filter="<text>", which runs fzf’s exact matcher non-interactively and prints every line that would match (the same stand-in the single-pick hack used). The act half — the kill, the git add, the git branch -D — is the real thing, run for real. fzf version:

$ fzf --version
0.44.1 (debian)

1. fkill -m — kill a handful of processes at once

One stuck dev server is single-pick fkill. Three orphaned workers from the run you Ctrl-C‘d wants multi-select: TAB each one, Enter, gone. The -m in these section titles is fzf’s own flag, baked into each function — you still invoke them by bare name (fkill, or fkill -KILL when the default TERM isn’t enough; that first argument is the signal, not a flag, so don’t type fkill -m — it would hand kill a bogus -m).

fkill() {
  local pids
  pids=$(ps -eo pid,comm,args --no-headers | fzf -m --height 40% --reverse | awk '{print $1}')
  [ -n "$pids" ] && kill "${1:--TERM}" $pids   # $pids UNQUOTED — on purpose
}

Watch it take three processes in one go (the picker stand-in returns all three ztask_* lines, exactly as if you’d TAB‘d them):

$ # three victims, uniquely named so the demo is deterministic:
$ ( exec -a ztask_api sleep 900 ) & ( exec -a ztask_worker sleep 900 ) & ( exec -a ztask_cron sleep 900 ) &
$ ps -eo pid,comm,args --no-headers | fzf -m --filter="ztask"
   6434 sleep           ztask_api 900
   6436 sleep           ztask_cron 900
   6435 sleep           ztask_worker 900
$ pids=$(ps -eo pid,comm,args --no-headers | fzf -m --filter="ztask" | awk '{print $1}')
$ kill $pids
$ ps -eo pid,comm,args --no-headers | grep -E 'ztask_(api|worker|cron)' || echo "(all three gone)"
(all three gone)

You’ll know it worked when all of them disappear from ps in one command.

Why $pids is the one expansion you DON’T quote

The whole previous hack hammered “quote every fzf result.” Here we do the opposite, and it’s not sloppiness — it’s the mechanism. fzf -m returns three PIDs separated by newlines. kill wants three separate arguments. The unquoted $pids lets the shell word-split that one multi-line string into three words — which is exactly what we need. Quote it and you hand kill a single argument with newlines jammed inside:

$ pids=$'111\n222\n333'

$ kill "$pids"     # QUOTED: one bogus argument
bash: kill: 111
222
333: arguments must be process or job IDs

$ kill $pids       # UNQUOTED: three arguments
bash: kill: (111) - No such process
bash: kill: (222) - No such process
bash: kill: (333) - No such process

(Those PIDs don’t exist, so kill complains three times — but look at the shape: three separate complaints means three separate arguments arrived. That’s the win.) Word-splitting is usually the bug; with -m it’s the feature. The "${1:--TERM}" is still quoted, because the signal is one argument — quote the singular, leave the plural bare.

The guard that stops an empty pick

Hit Esc and pick nothing, and $pids is empty. Without the [ -n "$pids" ] guard, kill runs with no targets:

$ empty=""
$ kill $empty
kill: usage: kill [-s sigspec | -n signum | -sigspec] pid | jobspec ... or kill -l [sigspec]

Harmless for kill — it prints usage and stops. Less harmless for the next two functions, where the act can be destructive and “no arguments” sometimes means “everything in scope.” The guard is one test; put it on all three.

2. fbrd -m — delete a handful of branches

After a week of spikes you’ve got spike/this and spike/that and three more, all merged or abandoned. List branches, TAB the dead ones, Enter.

fbrd() {
  local branches
  branches=$(git branch | sed 's/^[* ] //' | fzf -m --height 40% --reverse)
  [ -n "$branches" ] && git branch -D $branches   # unquoted: branch names have no spaces
}

The sed 's/^[* ] //' strips the * current-branch marker and the leading indent, same as in the single-pick fbr. Unquoted $branches is safe for the same reason $pids was: git refuses to create a branch name with a space in it, so word-splitting can only ever split on the newlines between names.

$ git branch
  keep/ccc
* master
  spike/aaa
  spike/bbb
$ git branch | sed 's/^[* ] //' | fzf -m --filter="spike"
spike/aaa
spike/bbb
$ branches=$(git branch | sed 's/^[* ] //' | fzf -m --filter="spike")
$ git branch -D $branches
Deleted branch spike/aaa (was 99e4a6a).
Deleted branch spike/bbb (was 99e4a6a).
$ git branch
  keep/ccc
* master

You’ll know it worked when git branch is shorter by exactly the count you picked, and keep/ccc — which you didn’t pick — is untouched.

3. fadd -m — stage a handful of files (the honest exception)

Here’s where “just don’t quote it” stops being free, because filenames can contain spaces and PIDs and branch names can’t. This is the one function in the set that has to do real work to stay correct.

The naive version copies the $pids trick onto files and quietly breaks. Watch a single weekly report.md detonate the unquoted expansion:

$ printf '%s\n' "app.py" "weekly report.md"
app.py
weekly report.md
$ picks=$(printf '%s\n' "app.py" "weekly report.md")
$ git add $picks          # UNQUOTED — the space splits the name in two
fatal: pathspec 'weekly' did not match any files

The space inside weekly report.md word-split into weekly and report.md — two paths that don’t exist — and the file you meant never got staged. So for files you go back to quoting. But git add "$picks" won’t do it either: that’s one argument again, and you want several. The answer is a bash array, read on newlines, expanded quoted as "${arr[@]}" (each element one argument, spaces preserved):

$ readarray -t arr <<< "$(printf '%s\n' "app.py" "weekly report.md")"
$ git add "${arr[@]}"
$ git status --porcelain
A  app.py
A  "weekly report.md"

Both staged, space and all. One more trap, and it’s a sneaky one: don’t build that list from git status --porcelain, because porcelain wraps spaced paths in double-quotes and core.quotePath=false does not turn that off (it only controls non-ASCII escaping). Feed those literal quotes to git add and it looks for a file actually named "weekly report.md":

$ git status --porcelain
?? app.py
?? "weekly report.md"
$ readarray -t files < <(git status --porcelain | cut -c4-)
$ git add "${files[@]}"
fatal: pathspec '"weekly report.md"' did not match any files

The clean source is git ls-files with -z (NUL-delimited, no quoting), piped into fzf with --read0/--print0 so the NULs survive the round trip, and read back with readarray -d '':

fadd() {
  local files
  readarray -d '' files < <(
    git ls-files -mo --exclude-standard -z | fzf -m --read0 --print0 --height 40% --reverse
  )
  [ ${#files[@]} -gt 0 ] && git add -- "${files[@]}"   # QUOTED array
}

Run against the same spaced file, this time it stages cleanly:

$ git ls-files -mo --exclude-standard -z | tr '\0' '|'
README.md|app.py|test_app.py|weekly report.md|
$ readarray -d '' files < <(git ls-files -mo --exclude-standard -z | fzf -m --read0 --print0 --filter="weekly")
$ printf '   file = <%s>\n' "${files[@]}"
   file = <weekly report.md>
$ git add -- "${files[@]}"
$ git status --porcelain
A  "weekly report.md"

You’ll know it worked when a path with a space in it shows up staged (A) instead of throwing pathspec ... did not match. The empty-pick guard here is [ ${#files[@]} -gt 0 ] — an array’s length, not a string’s emptiness — and it matters more than the others: a bare git add -- with no paths is a no-op today, but the array guard is the habit that keeps a future git rm/git clean multi-select from acting on a pick you never made.

The one rule, stated honestly

fzf -m returns many lines, and the act on the end takes many arguments. How you bridge the two depends on one question — can an item contain a space?

  • No (PIDs, branch names): leave the result unquoted and let word-splitting do the work. kill $pids, git branch -D $branches.
  • Yes (filenames, anything user-named): word-splitting is a bug. Use a NUL-clean arrayls-files -zfzf --read0 --print0readarray -d ''"${arr[@]}".

When this goes wrong

  • fkill -m kills nothing / errors on a name. You quoted $pids, so kill got one newline-stuffed argument. Drop the quotes: kill $pids.
  • fbrd says branch ... not found. The sed didn’t strip the * marker, so a * master with the star slipped through. Confirm the sed 's/^[* ] //' is there.
  • fadd throws pathspec 'weekly' did not match. You unquoted a file list and a filename had a space. Switch to the array form above.
  • fadd throws pathspec '"weekly report.md"' did not match (with quotes in the error). You sourced the list from git status --porcelain, which quotes spaced paths. Use git ls-files ... -z instead.
  • fadd errors with readarray: -d: invalid option. Your bash predates 4.4 (macOS still ships 3.2, where readarray -d doesn’t exist). fkill and fbrd need nothing newer and still work; for fadd, install a current bash (brew install bash) and run the function under that.
  • Nothing happens at all. Empty pick, guard did its job. That’s the guard working, not failing.

Three functions, one decision per function. Paste them in, open a new shell, and the next time you’re about to kill four PIDs by hand or git branch -D a week’s worth of spikes, you’ll TAB, TAB, TAB, Enter instead.