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 array —
ls-files -z→fzf --read0 --print0→readarray -d ''→"${arr[@]}".
When this goes wrong
fkill -mkills nothing / errors on a name. You quoted$pids, sokillgot one newline-stuffed argument. Drop the quotes:kill $pids.fbrdsaysbranch ... not found. Theseddidn’t strip the*marker, so a* masterwith the star slipped through. Confirm thesed 's/^[* ] //'is there.faddthrowspathspec 'weekly' did not match. You unquoted a file list and a filename had a space. Switch to the array form above.faddthrowspathspec '"weekly report.md"' did not match(with quotes in the error). You sourced the list fromgit status --porcelain, which quotes spaced paths. Usegit ls-files ... -zinstead.fadderrors withreadarray: -d: invalid option. Your bash predates 4.4 (macOS still ships 3.2, wherereadarray -ddoesn’t exist).fkillandfbrdneed nothing newer and still work; forfadd, 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.