結論
--select-1は候補が 1 件なら、--exit-0は 0 件なら、一覧を出さずに終わる。「fzf で人が選ぶ」前提の導線でも、1 件に絞れた候補はそのまま確定する--queryの既定はあいまい一致で、行全体を照合する。apiはapps-migrationに当たり、--exactでもmainはdomain-modelに当たる。行と完全に一致させるのは^main$だけ- fzf は
--filter以外では起動時に/dev/ttyを開く。制御端末の無いプロセスでは、--select-1で 1 件に絞れてもfailed to open /dev/ttyで exit 2 になる - 終了コードは 0 (選んだ) / 1 (一致なし) / 2 (エラー) / 130 (中断)。
--expect=(キー無し) は 2。producer | fzfは、fzf が先に終わると producer が SIGPIPE の 141 で終わり、pipefailで全体が 141 になる {}{1}はシングルクォートで囲んだ 1 引数に展開される。自分で"{}"と囲むと、そのクォートが値に混ざる- preview は行ごとに別の
$SHELL -cで走り、呼び出し元の関数と export していない変数は見えない --multiの出力は選んだ順。--tacは入力の並びを反転するだけで、カーソルは常に並びの先頭に乗る
以下は fzf 0.74.3 / bash 5.3.15 / linux/amd64 で実測した。対話の操作は script コマンドが作る疑似端末 (pty) の中で fzf を動かし、キーを送って再現した。
前提: fzf は 3 つの経路を使い分ける

fzf は 1 回の実行で、次の 3 つの経路を別々に使う。
- 候補: 標準入力から 1 行 1 件で読む
- 画面とキー:
/dev/tty(プロセスの制御端末) を開いて描画し、キーを読む - 結果: 選んだ行を標準出力へ 1 行 1 件で書き、終了コードで結果の種類を返す
$(fzf) で結果を受け取っても画面が出るのは、画面が標準出力ではなく /dev/tty に出るため。逆に、制御端末が無いと 2 の経路を開けない。
終了コードは man fzf の EXIT STATUS のとおり。
EXIT STATUS
0 Normal exit
1 No match
2 Error
126 Permission denied error from become action
127 Invalid shell command for become action
130 Interrupted with CTRL-C or ESC以下の節は、3 つの経路と終了コードのどこで、呼び出し側の想定と違う値が返るかを順に扱う。
--select-1 と --exit-0 は一覧を出さずに終わる

man fzf は 2 つのオプションを次のとおり書いている。
-1, --select-1
If there is only one match for the initial query (--query), do not start interactive finder and automatically select the only match
-0, --exit-0
If there is no match for the initial query (--query), do not start interactive finder and exit immediately$ printf 'deploy-prod\n' | fzf --select-1; echo "exit=$?"
deploy-prod
exit=0
$ printf 'deploy-prod\ndeploy-stg\n' | fzf --query prod --select-1; echo "exit=$?"
deploy-prod
exit=0
$ printf 'deploy-prod\ndeploy-stg\n' | fzf --query zzz --exit-0; echo "exit=$?"
exit=1候補が 2 件以上残ったときだけ一覧を出し、キーを待つ。誰も押さなければ待ち続け、ESC で 130 になる。
$ printf 'deploy-prod\ndeploy-stg\n' | fzf --query deploy --select-1; echo "exit=$?"
exit=130 # 一覧が出て、ESC を押した- PITFALL: 「fzf に並べて人に選ばせる」と説明したランチャでも、候補が 1 件なら確認の画面を出さずに確定する。共有の環境へ書き込む候補が 1 件だけ当たると、そのまま走る
- PITFALL:
--exit-0で 0 件のときは、何も出力せず exit 1 で終わる。呼び出し側が exit 1 を見てメッセージを出さないと、失敗したことも、その理由もログに残らない
--query のあいまい一致で、別の候補が確定する

--query の既定はあいまい一致 (query の文字が、その順で行のどこかに現れれば当たる)。存在しない名前を渡しても、文字が散らばって含まれる別の候補に当たる。--select-1 と組むと、その候補で確定する。
$ names=$'apps-migration\nfeature-auth\nfix-login'
$ fzf --query api --select-1 <<<"$names"; echo "exit=$?"
apps-migration
exit=0
$ fzf --filter api --exact <<<"$names"; echo "exit=$?" # --exact は部分文字列一致
exit=1--exact (と query 先頭の ') は部分文字列一致なので、名前の一部に当たれば確定する。行全体と一致させるのは、先頭の ^ と末尾の $ を両方付けた query だけ。
$ names2=$'domain-model\nfeature-auth'
$ fzf --query main --select-1 --exact <<<"$names2"; echo "exit=$?"
domain-model
exit=0
$ fzf --filter "'main" <<<"$names2"; echo "exit=$?"
domain-model
exit=0
$ fzf --filter '^main$' <<<"$names2"; echo "exit=$?"
exit=1
$ fzf --filter '^main$' <<<$'domain-model\nmain'; echo "exit=$?"
main
exit=0照合の対象は行全体。名前の前に状態の列があると、その列の文字にも当たる。--nth=-1 で最後の列だけに絞る。
$ rows=$'[merged] feature-auth\n[open] apps-migration'
$ fzf --query merged --select-1 --exact <<<"$rows"; echo "exit=$?"
[merged] feature-auth
exit=0
$ fzf --filter merged --exact --nth=-1 <<<"$rows"; echo "exit=$?"
exit=1既定では query の ^ $ ! ' を検索の構文として扱う (extended-search mode)。--no-extended を付けると、どれも文字として探す。
$ fzf --filter '!open' <<<"$rows"; echo "exit=$?" # !open は「open を含まない行」
[merged] feature-auth
exit=0
$ fzf --filter '!open' --exact --no-extended <<<"$rows"; echo "exit=$?"
exit=1「引数があれば絞り込んで 1 件なら確定、無ければ一覧」のラッパーは、絞り込みを部分文字列・最後の列・構文なしにする。
args=(--prompt 'select> ')
if [[ -n $query ]]; then
args+=(--query "$query" --select-1 --exact --no-extended --nth=-1)
fi
fzf "${args[@]}"--exit-0は付けない。0 件のときに一覧が出て、query を打ち直せる- 部分文字列一致は残るので、
mainを渡すとdomain-modelで確定する。行全体と一致させるなら^main$を使う。^と$は extended-search の構文なので、--no-extendedとは両立しない - PITFALL: query の表記が候補と揃っていないと当たらない。
feature/fooのような入力をfeature-foo形式のディレクトリ名の一覧に当てるなら、query を先に揃える
端末が無いと --select-1 でも exit 2 で終わる

fzf 0.74.3 は、--filter 以外では候補を読む前に /dev/tty を開く (src/core.go の Run が --filter でないときに NewTerminal を呼び、NewTerminal が tty を開けなければ exit 2 で返す)。--select-1 で一覧を出さずに終わる場合も、先に端末が要る。
setsid で制御端末を外したプロセスから呼ぶ。
$ setsid -w bash -c 'printf "a\n" | fzf --select-1; echo "exit=$?"'
failed to open /dev/tty
exit=2
$ setsid -w bash -c 'printf "a\nb\n" | fzf --query a --select-1 --exact; echo "exit=$?"'
failed to open /dev/tty
exit=2
$ setsid -w bash -c 'printf "a\nb\n" | fzf --query zzz --exit-0; echo "exit=$?"'
failed to open /dev/tty
exit=2
$ setsid -w bash -c 'printf "a\nb\n" | fzf --filter a --exact; echo "exit=$?"'
a
exit=0端末が無くても動くのは --filter だけ。--filter は一覧を出さず、当たった行をすべて出力する (0 件なら exit 1)。1 件かどうかは呼び出し側で数える。
制御端末はあるが、キーを押す人がいない (端末の中で自動実行が走る) 場合は、一覧を出してキーを待ち続ける。
- PITFALL: 非 0 で終わったときに、引数を落として素の一覧へ戻るリトライを書くと、端末が無ければもう一度 exit 2、端末があって人がいなければ待ち続ける。非 0 は再試行せず中断する
- PITFALL:
printf 'a\nb\n' | fzf ... < /dev/nullのように< /dev/nullを足すと、パイプの代わりに/dev/nullが標準入力になり、候補が 0 件になる
--expect= (キー無し) は exit 2 で終わる

--expect は、Enter 以外で確定させるキーを並べるオプション。キーを 1 つも書かないとエラーになる。
$ printf 'a\n' | fzf --expect= --select-1; echo "exit=$?"
key names required
exit=2
$ printf 'a\n' | fzf --expect=ctrl-v --select-1; echo "exit=$?"
a
exit=0--expect を付けると、出力の 1 行目は押したキーの名前になる。Enter (と --select-1 の自動確定) では空行になる。
fzf 本体に同梱の Vim プラグイン (plugin/fzf.vim) は、fzf#wrap で g:fzf_action のキーを --expect に並べる。g:fzf_action が未設定なら既定のキーが入り、空の dict なら --expect= になる。
$ vim -Nu NONE -es ... -c 'unlet! g:fzf_action' -c 'let o = fzf#wrap({})' -c 'put =string(o.options)' ...
' --expect=ctrl-v,ctrl-x,ctrl-t'
$ vim -Nu NONE -es ... -c 'let g:fzf_action = {}' -c 'let o = fzf#wrap({})' -c 'put =string(o.options)' ...
' --expect='g:fzf_action を一時的に上書きして戻す処理では、元が未設定だったかを記録し、未設定なら unlet! で未設定に戻す。
let s:had_action = exists('g:fzf_action')
let s:saved_action = get(g:, 'fzf_action', {})
let g:fzf_action = {'ctrl-o': 'edit'}
put ='during: ' . fzf#wrap({}).options
if s:had_action
let g:fzf_action = s:saved_action
else
unlet! g:fzf_action
endif
put ='after: exists=' . exists('g:fzf_action') . fzf#wrap({}).optionsduring: --expect=ctrl-o
after: exists=0 --expect=ctrl-v,ctrl-x,ctrl-t- PITFALL:
get(g:, 'fzf_action', {})の既定値{}をそのままlet g:fzf_action = s:saved_actionで書き戻すと、未設定だった変数が空の dict として残る。同じ手順で、戻した後はafter: exists=1 --expect=になり、以後のfzf#wrapを通る fzf はすべて exit 2 で終わる
producer | fzf は、fzf が先に終わると producer が SIGPIPE で終わる

パイプの読み手が先に終わると、書き手は閉じたパイプへ書いた時点で SIGPIPE を受け、終了コード 141 (128 + 13) で終わる。set -o pipefail の下では、パイプ全体の終了コードが 141 になる。
1 行目を出した後、2 秒遅れて残りを書く producer で試す。1 行目が出た時点で Enter を押す。
set -o pipefail
produce() { echo first; sleep 2; seq 100000; }
sel=$(produce | fzf) || echo "fallback: status=$?"
echo "sel=$sel"
produce | fzf > /dev/null; echo "PIPESTATUS=${PIPESTATUS[*]}"fallback: status=141
sel=first
PIPESTATUS=141 0fzf は exit 0 で first を返し、sel にも入っている。それでも || の側へ進む。
出力を先に変数へ確定させてから渡すと、producer は fzf を起動する前に書き終わっているので、SIGPIPE を受けない。
rows=$(produce) # 先に出力を確定させる
sel=$(fzf <<<"$rows") || : # パイプにしないrows: 100001 lines
sel=first- PITFALL: producer の出力が小さいと、fzf が読み終える前に書き終わるので再現しない。テストでは通り、出力が大きいときや、すぐ Enter を押したときにだけ起きる
- PITFALL:
|| return 0のような fallback と組むと、選んだのに何も起きない。pipefailと終了コードの関係は bash の set -e が効かない場所と効きすぎる場所 にもまとめた
{} と {1} はクォート済みの 1 引数になる

--bind の execute(...) become(...) や --preview に書いた {} (行全体) と {1} (区切った 1 列目) は、シングルクォートで囲んだ文字列に置き換わる。man fzf は次のとおり書いている。
Each expression expands to a quoted string, so that it's safe to pass it as an argument to an external command. So you should not manually add quotes around the curly braces.置き換えた後のコマンド文字列は、--with-shell に printf を渡すと見られる (fzf は --with-shell の後ろに、置き換えた後のコマンド文字列を 1 引数で付けて実行する)。
$ line="it's a file.txt"
$ fzf --with-shell 'printf cmd=%s\n' --bind 'enter:become(printf "[%s]\n" {})' <<<"$line"
cmd=printf "[%s]\n" 'it'\''s a file.txt'
$ fzf --bind 'enter:become(printf "[%s]\n" {})' <<<"$line"
[it's a file.txt]空白とシングルクォートを含む行が、1 つの引数でそのまま届く。自分で "{}" と囲むと、fzf が付けたクォートが値の一部になる。
$ fzf --with-shell 'printf cmd=%s\n' --bind 'enter:become(printf "[%s]\n" "{}")' <<<"$line"
cmd=printf "[%s]\n" "'it'\''s a file.txt'"
$ fzf --bind 'enter:become(printf "[%s]\n" "{}")' <<<"$line"
['it'\''s a file.txt']r フラグ ({r}) はクォートを付けず、空白で別の引数に分かれる。
$ fzf --bind 'enter:become(printf "[%s]\n" {r})' <<<"a file.txt"
[a]
[file.txt]--delimiter と --with-nth で 1 列目を表示から隠すと、表示を短くしたまま、子プロセスへは 1 列目の値を 1 引数で渡せる。
$ printf '%s\t%s\n' "/srv/data/it's a file.txt" "it's a file" |
fzf --delimiter '\t' --with-nth 2 --bind 'enter:become(printf "[%s]\n" {1})'
[/srv/data/it's a file.txt]- PITFALL: クォートされるのは
{}を置き換えた部分だけ。cmd=の出力のとおり、周りに書いたコマンドの文字列は書いたまま実行されるので、そちらのクォートは自分で書く
preview は行ごとに別のシェルで走る

--preview のコマンドは、カーソルが乗った行ごとに、別プロセスの $SHELL -c で走る。呼び出し元のスクリプトで定義した関数と、export していない変数は見えない。
show() { echo "show: $1"; }
VAR=local-only
export EXPORTED=exported
fzf --preview 'echo "pid=$$ line={}" >> preview.out; show {} >> preview.out 2>&1; echo "VAR=$VAR EXPORTED=$EXPORTED" >> preview.out' \
<<<$'a.txt\nb.txt'a.txt の preview が出た後、b.txt へ移って Enter を押した。
pid=495 line='a.txt'
/bin/bash: line 1: show: command not found
VAR= EXPORTED=exported
pid=498 line='b.txt'
/bin/bash: line 1: show: command not found
VAR= EXPORTED=exported行ごとに pid が違い、関数 show は未定義で、VAR は空になる。export した EXPORTED だけが届く。
一覧と preview で同じ処理を使うなら、スクリプト自身を、preview 用の引数を付けて絶対パスで呼び直す。
#!/bin/bash
show() { echo "show: $1"; }
if [[ $1 == --preview-line ]]; then
show "$2"
exit
fi
self=$(readlink -f "$0")
cd /tmp || exit 1 # 一覧を作る前に別の場所へ移るスクリプトを想定する
fzf --preview "$self --preview-line {}" <<<$'a.txt\nb.txt'preview に出た内容 (確かめるために、preview の出力をファイルへも書いて読んだ):
show: a.txt- PITFALL:
self=$0のまま相対パスで呼び直すと、スクリプトが fzf の前にcdしていれば preview から見つからない。sub/から../self.shとして起動すると、preview は/bin/bash: line 1: ../self.sh: No such file or directoryになる
--multi の出力は選んだ順に 1 行 1 件

--multi (-m) で複数を選ぶと、入力の順ではなく選んだ順に出力する。入力の順で出すオプションは無い。
a b c の一覧で、c を Tab で選び、続けて a を選んで Enter を押す。
$ printf 'a\nb\nc\n' | fzf --multi
c
a入力の順に戻すには、行番号を付けて表示から隠し、選んだ後に番号で並べ直す。同じ操作で a c の順になる。
$ printf 'a\nb\nc\n' | nl -ba | fzf --multi --with-nth=2.. | sort -k1,1n | cut -f2-
a
c出力は 1 行 1 件なので、空白で繋いでから展開すると、空白を含む名前が分かれる。
list=$'my file.txt\nb.txt'
files=$(fzf --multi --bind 'load:select-all+accept' <<<"$list" | tr '\n' ' ')
printf '[%s]\n' $files[my]
[file.txt]
[b.txt]1 行ずつ配列に入れ、"${files[@]}" で渡す。
files=()
while IFS= read -r f; do
[[ -n $f ]] && files+=("$f")
done < <(fzf --multi --bind 'load:select-all+accept' <<<"$list")
printf '[%s]\n' "${files[@]}"[my file.txt]
[b.txt]- PITFALL:
# shellcheck disable=SC2086でクォートの警告を消すと、空白を含む名前が来るまで動いて見える - PITFALL:
read -rだけでIFS=を付けないと、行の先頭と末尾の空白が削られる
--tac は入力の並びを反転し、カーソルは並びの先頭に乗る

並びに効くオプションは 2 つあり、別のものを変える。
--tac: 入力の順を反転する (man fzf: “Reverse the order of the input”)--reverse(--layout=reverse): 画面のレイアウトを反転する。既定 (default) は画面の下から上へ並べ、reverseは上から下へ並べる
カーソルの初期位置は、どちらの組み合わせでも並びの先頭 (既定のレイアウトでは最下段、--reverse では最上段)。起動してすぐ Enter を押すと、その行が出る。
$ printf '1\n2\n3\n' | fzf # => 1
$ printf '1\n2\n3\n' | fzf --tac # => 3
$ printf '1\n2\n3\n' | fzf --reverse # => 1
$ printf '1\n2\n3\n' | fzf --tac --reverse # => 3画面は次のとおり (--height=6 --pointer='>' --gutter=' ' で描き、端末エミュレータ pyte で読み取った。2 つずつ横に並べ、区切り線を縮めた)。
fzf fzf --tac
3 1
2 2
> 1 > 3
3/3 ────── 3/3 ──────
> >
fzf --reverse fzf --tac --reverse
> >
3/3 ────── 3/3 ──────
> 1 > 3
2 2
3 1打鍵なしで選ばせたい候補があるなら、既定のレイアウトのまま、その候補を入力の 1 行目に置き、--tac は付けない。降順で作った入力を昇順で見せたいときだけ --tac を付ける。
- PITFALL: 別のスクリプトが
--tacを使っているからといって写すと、入力を並べる向きが違えば、画面の上下とカーソルの位置が逆になる
まとめ
| 場面 | 起きること | 対策 |
|---|---|---|
--select-1 / --exit-0 | 1 件なら一覧を出さずに確定、0 件なら出力なしで exit 1 | 人に選ばせたい導線では付けない。exit 1 は呼び出し側でメッセージを出す |
--query | 既定はあいまい一致で行全体を照合。--exact も部分文字列 | --exact --no-extended --nth=-1。行全体の一致は ^...$ |
| 制御端末が無い | --filter 以外は failed to open /dev/tty で exit 2 | 端末の無い経路では --filter を使い、件数は自分で数える |
--expect= | key names required で exit 2 | g:fzf_action は exists() で記録し、未設定なら unlet! で戻す |
producer | fzf | fzf が先に終わると producer が 141、pipefail で全体が 141 | 出力を変数に確定させてから <<< で渡す |
{} / {1} | シングルクォートで囲んだ 1 引数になる | 自分で "{}" と囲まない。表示と値は --with-nth で分ける |
--preview | 行ごとに別の $SHELL -c。関数と export していない変数は見えない | スクリプト自身を絶対パスで呼び直す |
--multi | 選んだ順に 1 行 1 件 | 入力の順は nl で番号を付けて並べ直す。配列で受ける |
--tac | 入力の順を反転する。カーソルは並びの先頭 | 選ばせたい候補を入力の 1 行目に置く |
参考
- junegunn/fzf
man fzf(fzf 0.74.3)