diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae8a7b4c..a37183a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,10 @@ jobs: severity: error - name: Run ShellCheck on install.sh run: shellcheck --severity=error install.sh + # base イメージへ入れる tmux のコマンド (PLAN69)。指摘 0 件の状態なので severity は + # 絞らない (既定の style まで)。bin/ の step が error に絞っているのは既存の指摘のため (#247)。 + - name: Run ShellCheck on containers/base/tmux-* + run: shellcheck containers/base/tmux-first containers/base/tmux-clean containers/base/tmux-session pytest: name: Pytest (Python ${{ matrix.python-version }}) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51b12818..8d7a228f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,16 @@ Ubuntu のアーカイブの版(2026-09 時点で 0.11.0)が入ります。入れ損ないはビルドの版の確認で 止まります。`lfm` / `snapshot` は base を継がないため入りません。 **反映には `devbase build base --no-cache` と、使っている派生イメージの建て直しが要ります。** +- **tmux のセッションを名指しで移る・調べる・落とすコマンドを base イメージに入れました + (PLAN69 / #234)。** `tmux-go <名前>` はそのセッションへ移って他の端末を外し、 + `tmux-peek <名前>` は attach せずに端末・プロセス・画面の直近を出し、`tmux-kill <名前>...` は + attach 中・実行中を問わず落とします(本体は `tmux-session`、名前は完全一致)。tmux の中では + `prefix S` でセッションの一覧から選び、同じ 3 つをメニューから呼べます。`tmux-first` / + `tmux-clean` は変わりません。ホストの tmux で使う手順は + `docs/user/environment-variables.md` の「セッションを名指しで扱う」にあります。 + **反映には `devbase build base --no-cache` と、使っている派生イメージの建て直し、 + コンテナの作り直し(`devbase down` → `devbase up`)が要ります。`devbase up` だけでは + 反映されません。** ### Changed - **スナップショットの世代を、アカウントグループ(対象ボリュームの組)ごとの系列で持つように diff --git a/containers/base/Dockerfile b/containers/base/Dockerfile index 90743b18..57e02375 100644 --- a/containers/base/Dockerfile +++ b/containers/base/Dockerfile @@ -250,14 +250,21 @@ COPY --chmod=0644 tmux.conf /etc/tmux.conf # tmux セッションの整理コマンド。tmux-first は居座りクライアントを切断して # 一番若い番号のセッションへ切り替え、tmux-clean は置き去りのセッションを削除する。 +# tmux-session は 1 つのセッションを名指しで移る・調べる・落とす (#234)。prefix S の +# メニュー (tmux.conf) からも呼ばれる。 # 背景と使い方は各スクリプト冒頭のコメントと docs/user/environment-variables.md を参照。 COPY --chmod=0755 tmux-first /usr/local/bin/tmux-first COPY --chmod=0755 tmux-clean /usr/local/bin/tmux-clean +COPY --chmod=0755 tmux-session /usr/local/bin/tmux-session # 短縮名は alias ではなく symlink で用意する。alias は bash の対話シェルにしか効かず、 # zsh などの他シェルや非対話実行 (docker exec など) では使えないため。 +# tmux-go / tmux-peek / tmux-kill は呼ばれた名前で tmux-session のサブコマンドになる。 RUN sudo ln -sf tmux-first /usr/local/bin/tmux1 \ - && sudo ln -sf tmux-clean /usr/local/bin/tmuxc + && sudo ln -sf tmux-clean /usr/local/bin/tmuxc \ + && sudo ln -sf tmux-session /usr/local/bin/tmux-go \ + && sudo ln -sf tmux-session /usr/local/bin/tmux-peek \ + && sudo ln -sf tmux-session /usr/local/bin/tmux-kill # フォントの既定。素の fontconfig は sans-serif を中国語フェイス (WenQuanYi Zen Hei) へ # 向けるため、日本語を描くと中国語の字形で写る (#161)。/etc/fonts/local.conf は diff --git a/containers/base/tmux-session b/containers/base/tmux-session new file mode 100755 index 00000000..188cf886 --- /dev/null +++ b/containers/base/tmux-session @@ -0,0 +1,377 @@ +#!/bin/sh +# tmux-session - 1 つの tmux セッションを名指しで、移る・調べる・落とす (#234)。 +# +# tmux-first / tmux-clean は「同じベース名のセッション群」を、操作中の端末と実行中の +# セッションを守りながら整理する。こちらは 1 つを狙って強制的に効かせる道具で、守りの +# 既定が逆になるため別のコマンドにしてある。 +# +# tmux-session go [-c 端末] <セッション> 移る。そのセッションの他の端末は外す +# tmux-session peek [-n 行数] <セッション> attach せずに端末・プロセス・画面を見る +# tmux-session kill [-n] [-f] [-c 端末] <セッション>... attach・実行中を問わず落とす +# tmux-session menu -c 端末 <セッション> prefix S の一覧から呼ばれるメニュー +# +# tmux-go / tmux-peek / tmux-kill という名前 (symlink) で呼ぶと、そのサブコマンドになる。 +# 短縮名を alias にしないのは、alias が bash の対話シェルにしか効かないため。 +# +# セッションの指し方: `$数字` は ID、それ以外は名前の完全一致 (devbase-1 は devbase-10 に +# 当たらない)。`$数字` は名前へ落とさない。メニューは ID を渡すため、確認を待つ間に +# 対象が終わって ID が消えたとき、同じ文字列の名前の別のセッションを落とさないため。 +# 解決した後の tmux の操作はすべて ID で行う。ID は `$` と数字だけでできており、tmux の +# コマンドにもシェルにも引用 1 段で安全に渡せる。 +set -eu + +PROG=$(basename "$0") +case "$PROG" in +tmux-go) SUB="go" ;; +tmux-peek) SUB="peek" ;; +tmux-kill) SUB="kill" ;; +*) + PROG=tmux-session + SUB="" + ;; +esac + +usage() { + cat <<'USAGE' +使い方: tmux-session <サブコマンド> [オプション] <セッション> + + tmux-session go [-c 端末] <セッション> + セッションへ移る。そのセッションに繋がっている他の端末は外す。 + tmux の外では attach する。tmux の中では実行元の端末を切り替える。 + tmux-session peek [-n 行数] <セッション> + attach せずに、繋がっている端末・pane のプロセス・画面の直近 (既定 20 行、 + -n 0 で出さない) を出す。 + tmux-session kill [-n] [-f] [-c 端末] <セッション>... + attach・実行中を問わず落とす。-n は落とさずに予定を出す。 + 自分の pane があるセッションは -f が無ければ落とさない。 + tmux-session menu -c 端末 <セッション> + 移る・中身を見る・落とすのメニューを出す (prefix S の一覧から呼ばれる)。 + + 短縮名: tmux-go = go / tmux-peek = peek / tmux-kill = kill + <セッション>: `$数字` は ID、それ以外は名前の完全一致。 + -c 端末: この端末の代わりに操作する。知らせは端末の状態行へ出す。 + +終了コード: 0 成功 / 1 対象が無い・サーバが無い・実行元を特定できない など / 2 使い方の誤り +USAGE +} + +usage_error() { + echo "$PROG: $1" >&2 + echo " 使い方は $PROG -h" >&2 + exit 2 +} + +if [ -z "$SUB" ]; then + [ $# -ge 1 ] || usage_error "サブコマンドがありません" + case "$1" in + -h | --help) + usage + exit 0 + ;; + go | peek | kill | menu) SUB=$1 ;; + *) usage_error "不明なサブコマンド: $1" ;; + esac + shift +fi + +CLIENT="" +HAVE_CLIENT=0 +LINES_N=20 +DRY=0 +FORCE=0 +while [ $# -gt 0 ]; do + case "$SUB:$1" in + *:-h | *:--help) + usage + exit 0 + ;; + go:-c | kill:-c | menu:-c) + [ $# -ge 2 ] || usage_error "-c に端末の名前が要ります" + CLIENT=$2 + HAVE_CLIENT=1 + shift 2 + ;; + peek:-n) + [ $# -ge 2 ] || usage_error "-n に行数が要ります" + LINES_N=$2 + shift 2 + ;; + kill:-n | kill:--dry-run) + DRY=1 + shift + ;; + kill:-f | kill:--force) + FORCE=1 + shift + ;; + *:--) + shift + break + ;; + *:-?*) usage_error "不明なオプション: $1" ;; + *) break ;; + esac +done + +# -c の値は tmux の端末名 (tty のパス /dev/pts/4 か client-) の形に限る。この範囲に +# 収めることで、menu が組む tmux のコマンドへ引用 1 段で埋め込める。 +if [ "$HAVE_CLIENT" = 1 ]; then + case "$CLIENT" in + '' | *[!A-Za-z0-9/_.-]*) usage_error "-c の値が端末の名前の形ではありません: $CLIENT" ;; + esac +fi +case "$LINES_N" in +'' | *[!0-9]*) usage_error "-n は 0 以上の整数で指定してください: $LINES_N" ;; +esac + +case "$SUB" in +kill) [ $# -ge 1 ] || usage_error "セッションを指定してください" ;; +*) + [ $# -ge 1 ] || usage_error "セッションを指定してください" + [ $# -eq 1 ] || usage_error "セッションは 1 つだけ指定してください" + ;; +esac +[ "$SUB" != menu ] || [ "$HAVE_CLIENT" = 1 ] || usage_error "menu には -c が要ります" + +# 知らせの出し先。-c のときは端末の状態行へ出し、標準出力には何も出さない +# (UI から背景の run-shell で呼ばれるため、出力は誰にも見えない)。 +say() { + if [ "$HAVE_CLIENT" = 1 ]; then + tmux display-message -l -c "$CLIENT" "$PROG: $1" 2>/dev/null || true + else + echo "$1" + fi +} +warn() { + if [ "$HAVE_CLIENT" = 1 ]; then + tmux display-message -l -c "$CLIENT" "$PROG: $1" 2>/dev/null || true + fi + echo "$PROG: $1" >&2 +} +fail() { + warn "$1" + exit 1 +} + +command -v tmux >/dev/null 2>&1 || fail "tmux が見つかりません" +SESSIONS=$(tmux list-sessions -F '#{session_id} #{session_name}' 2>/dev/null) || + fail "tmux サーバが起動していません" + +if [ "$HAVE_CLIENT" = 1 ]; then + tmux list-clients -F '#{client_name}' | grep -Fqx -- "$CLIENT" || + fail "端末がありません: $CLIENT" +fi + +# セッションを ID へ解決する。list-sessions の最初の空白より後ろ全体を名前として +# 比べる (tmux-clean と同じ方法。名前にどんな文字が入っても取り違えない)。 +# awk の -v は値の \ を解釈するため、比べる値は環境変数で渡す。 +resolve() { + case "$1" in + \$*[!0-9]* | \$) BY_ID=0 ;; + \$*) BY_ID=1 ;; + *) BY_ID=0 ;; + esac + printf '%s\n' "$SESSIONS" | WANT=$1 BY_ID=$BY_ID awk ' + { + id = $1 + name = substr($0, length(id) + 2) + if (ENVIRON["BY_ID"] == 1 ? id == ENVIRON["WANT"] : name == ENVIRON["WANT"]) { + print id + exit + } + }' +} +name_of() { + printf '%s\n' "$SESSIONS" | WANT=$1 awk '$1 == ENVIRON["WANT"] { print substr($0, length($1) + 2); exit }' +} + +# 自分のセッション: -c の端末が見ているセッション、無ければ自分の pane があるセッション。 +SELF_SID="" +if [ "$HAVE_CLIENT" = 1 ]; then + SELF_SID=$(tmux display-message -c "$CLIENT" -p '#{session_id}' 2>/dev/null || true) +elif [ -n "${TMUX:-}" ] && [ -n "${TMUX_PANE:-}" ]; then + SELF_SID=$(tmux display-message -p -t "$TMUX_PANE" '#{session_id}' 2>/dev/null || true) +fi + +do_kill() { + rc=0 + LAST="" + for want in "$@"; do + id=$(resolve "$want") + if [ -z "$id" ]; then + warn "セッションがありません: $want" + rc=1 + continue + fi + name=$(name_of "$id") + if [ "$id" = "$SELF_SID" ]; then + if [ "$FORCE" = 1 ]; then + # 自分のシェルに SIGHUP が届き、この処理自身も終わり得るため最後に回す。 + LAST=$id + else + warn "自分のセッションなので落としません (落とすには -f): $name" + rc=1 + fi + continue + fi + if [ "$DRY" = 1 ]; then + say "KILL $name (dry-run)" + elif tmux kill-session -t "$id"; then + say "KILL $name" + else + warn "落とせませんでした: $name" + rc=1 + fi + done + if [ -n "$LAST" ]; then + name=$(name_of "$LAST") + if [ "$DRY" = 1 ]; then + say "KILL $name (dry-run)" + else + say "KILL $name" + tmux kill-session -t "$LAST" || rc=1 + fi + fi + return "$rc" +} + +do_peek() { + id=$1 + name=$(name_of "$id") + counts=$(tmux display-message -p -t "$id" '#{session_windows} #{session_attached}') + printf 'session %s (%s) windows %s attached %s\n' "$name" "$id" "${counts% *}" "${counts#* }" + + echo "clients" + clients=$(tmux list-clients -t "$id" -F ' #{client_name} 最終操作 #{t:client_activity}') + if [ -n "$clients" ]; then + printf '%s\n' "$clients" + else + echo " (なし)" + fi + + # 子孫のプロセスは ps を 1 回だけ取り、pane_pid から辿る。pgrep -P は子しか出さず、 + # uv の下の python3 のような孫が見えない。-A -o pid= -o ppid= -o args= は Linux の + # procps と macOS の ps のどちらでも同じ意味になる。 + echo "panes" + { + tmux list-panes -s -t "$id" \ + -F '#{pane_pid} #{window_index}.#{pane_index} #{pane_current_command} pid=#{pane_pid} #{pane_current_path}' + echo "--ps--" + ps -A -o pid= -o ppid= -o args= + } | awk ' + function tree(pid, depth, i, n, list, pad) { + n = split(kids[pid], list, " ") + for (i = 1; i <= n; i++) { + pad = sprintf("%" (2 + depth * 2) "s", "") + printf "%s%s %s\n", pad, list[i], args[list[i]] + tree(list[i], depth + 1) + } + } + $0 == "--ps--" { inps = 1; next } + !inps { npane++; ppid_of[npane] = $1; line[npane] = substr($0, length($1) + 2); next } + { + pid = $1; parent = $2 + a = $0 + sub(/^[ \t]*[0-9]+[ \t]+[0-9]+[ \t]*/, "", a) + args[pid] = a + kids[parent] = kids[parent] (kids[parent] == "" ? "" : " ") pid + } + END { + for (p = 1; p <= npane; p++) { + printf " %s\n", line[p] + tree(ppid_of[p], 1) + } + }' + + # 画面は見えている範囲を取り、末尾の空行を除いてから最後の N 行を出す。 + # capture-pane -S -N は「履歴 N 行 + 画面全体」を返すため、直近 N 行にならない。 + if [ "$LINES_N" -gt 0 ]; then + where=$(tmux display-message -p -t "$id" '#{window_index}.#{pane_index}') + echo "screen ($where の直近 $LINES_N 行)" + tmux capture-pane -p -J -t "$id" | awk -v n="$LINES_N" ' + { l[NR] = $0; if ($0 !~ /^[ \t]*$/) last = NR } + END { + start = last - n + 1 + if (start < 1) start = 1 + for (i = start; i <= last; i++) print " " l[i] + }' + fi +} + +do_go() { + id=$1 + name=$(name_of "$id") + if [ "$HAVE_CLIENT" = 0 ] && [ -z "${TMUX:-}" ]; then + # -d: そのセッションに繋がっていた端末を外して attach する。 + exec tmux attach-session -d -t "$id" + fi + + # 実行元の端末。-c があればそれ。無ければ tmux-first と同じ規則で特定する + # (tmux-first の「自分自身のクライアント」のコメントを参照。規則を変えるときは両方直す)。 + # pane のシェルは起動元の端末を知らないため、tmux の「現在のクライアント」(最終操作が + # 最も新しいクライアント) を使い、最終操作が SELF_FRESH 秒以内のときだけ実行元と認める。 + # プロンプトから打った場合はそのキー入力で最終操作が更新される。 + ME=$CLIENT + if [ "$HAVE_CLIENT" = 0 ]; then + SELF_FRESH=10 + now=$(date +%s) + self=$(tmux display-message -p '#{client_activity}:#{client_name}' 2>/dev/null || true) + case "$self" in + [0-9]*:?*) + if [ "$((now - ${self%%:*}))" -le "$SELF_FRESH" ]; then + ME=${self#*:} + fi + ;; + esac + fi + if [ -z "$ME" ]; then + warn "実行元の端末を特定できないため、何も外さず切り替えもしません" + warn "手で切り替えるには: tmux switch-client -t '$id' ($name)" + exit 1 + fi + + # 外すのを切り替えより先にする。先に切り替えると実行元も対象の端末の一覧に入る。 + clients=$(tmux list-clients -t "$id" -F '#{client_name}') + while IFS= read -r c; do + [ -n "$c" ] || continue + [ "$c" = "$ME" ] && continue + tmux detach-client -t "$c" || warn "端末を外せませんでした: $c" + done < +tmux-session peek [-n 行数] <セッション> +tmux-session kill [-n] [-f] [-c 端末] <セッション>... +tmux-session menu -c 端末 <セッション> +tmux-go … = tmux-session go … +tmux-peek … = tmux-session peek … +tmux-kill … = tmux-session kill … +``` + +- `basename "$0"` が `tmux-go` / `tmux-peek` / `tmux-kill` なら第 1 引数をサブコマンドとして + 読まない。それ以外の名前で呼ばれたときは第 1 引数をサブコマンドとして読む +- `-h` / `--help` はどのサブコマンドでも使い方を標準出力へ出して終了コード 0 で終わる +- `kill` の `-n` は `--dry-run`、`-f` は `--force` とも書ける。`--` でオプションの終わりを示せる +- オプションはサブコマンドごとに受け付けるものが決まっている。`peek` は `-c` を、`go` は `-n` を + 受け取らない(知らないオプションとして終了コード 2) +- `go` / `peek` / `menu` はセッションをちょうど 1 つ、`kill` は 1 つ以上取る +- `-n 行数` は 0 以上の整数だけを受け付ける。既定は 20 + +### セッションの指し方 + +| 引数の形 | 解決 | +| --- | --- | +| `$` + 数字(例 `$3`) | その ID のセッションだけ。無ければ「無い」。同じ文字列の名前へは**落ちない** | +| それ以外 | 名前の**完全一致**。`devbase-1` は `devbase-10` に当たらない | + +`tmux list-sessions -F '#{session_id} #{session_name}'` を読み、最初の空白より後ろ全体を名前として +比べる(`tmux-clean` と同じ方法)。比べる値は `awk -v` ではなく環境変数で渡す(`-v` は値の `\` を +解釈するため)。名前に空白・`'`・`"`・`$`・`;` を含んでも取り違えない。 + +解決した後の tmux の操作はすべて ID(`-t '$3'`)で行う。ID は `$` と数字だけでできており、tmux の +コマンドにもシェルにも引用 1 段で渡せる。 + +**ID の形の引数を名前へ落とさないのは**、メニューが ID を渡すためである。確認を待つ間に対象が +終わって ID が消えたとき、名前へ落とすと `$3` という名前の別のセッションを落とす。代わりに、 +`$` と数字だけの名前のセッションはコマンドから名前で指せない(`prefix S` の一覧からは選べる)。 + +### `-c 端末` と知らせの出し先 + +`-c` は「この端末の代わりに操作する」ことを示す。`menu` が組む項目は必ず `-c` を付けて呼ぶ。 + +| 項目 | `-c` あり | `-c` なし | +| --- | --- | --- | +| 値の形 | `[A-Za-z0-9/_.-]` だけでできていること。外れたら・空なら終了コード 2 | — | +| 端末の存在 | `tmux list-clients` に無ければ終了コード 1 | — | +| 実行元の端末 | `-c` の値 | tmux の中なら「実行元の特定」の規則で決める。tmux の外なら無し | +| 自分のセッション | `-c` の端末が見ているセッション | `TMUX` と `TMUX_PANE` があれば、その pane があるセッション | +| 知らせ(`KILL …` など) | `tmux display-message -l -c 端末` で端末の状態行へ出す。標準出力には何も出さない | 標準出力 | +| 警告・誤り | 端末の状態行と標準エラーの両方 | 標準エラー | + +tmux の端末名は tty のパス(`/dev/pts/4`)か `client-` で、`-c` の値の形に収まる。この検査で +`menu` が組む tmux のコマンドへ引用 1 段で埋め込める。`display-message` に `-l` を付けるのは、 +名前などに含まれる `#` を書式として展開させないためである。 + +### 実行元の特定(`go` で `-c` が無く tmux の中にいるとき) + +`tmux-first` と同じ規則で決める(規則を変えるときは両方を直す)。pane のシェルは起動元の端末を +知らないため、`tmux display-message -p '#{client_activity}:#{client_name}'`(tmux の「現在の +端末」=最終操作が最も新しい端末)を読み、最終操作から **10 秒以内**(境界の 10 秒を含む)の +ときだけ実行元と認める。プロンプトから打った場合はそのキー入力で最終操作が更新される。 + +### `go`(移る) + +| 状況 | 振る舞い | +| --- | --- | +| tmux の外(`TMUX` が空)で `-c` なし | `exec tmux attach-session -d -t '$ID'`。`-d` で、そのセッションに繋がっていた端末を外す | +| 実行元が決まった(`-c` あり、または特定できた) | `list-clients -t '$ID'` の端末のうち実行元以外を `detach-client -t` で外し、**その後で**実行元が見ているセッションが対象と違えば `switch-client -c 実行元 -t '$ID'` で切り替える | +| tmux の中で実行元を特定できない | 何も外さず、切り替えず、手で切り替えるコマンド(`tmux switch-client -t '$ID'` と名前)を標準エラーへ出して終了コード 1 | + +- 外すのを切り替えより先にする。先に切り替えると実行元も対象のセッションの端末の一覧に入る +- 別のセッションに繋がっている端末は `list-clients -t '$ID'` に出ないため、触らない +- 対象が実行元の今のセッションなら、他の端末を外すだけで切り替えない +- ある端末を外せなかったときは警告を出して残りの端末へ進み、切り替えも行う。切り替えに + 失敗したときは終了コード 1 + +### `peek`(調べる) + +読むだけで、tmux の状態(繋がっている端末・セッションの一覧)を変えない。標準出力へ次の 4 節を +この順に出す。 + +```text +session devbase-2 ($3) windows 2 attached 1 +clients + /dev/pts/4 最終操作 Thu Sep 24 10:31:02 2026 +panes + 0.0 uv pid=1234 /work/devbase + 1300 uv run devbase list + 1310 python3 …/devbase list + 1.0 bash pid=1400 /work +screen (0.0 の直近 20 行) + … +``` + +| 節 | 取り方 | +| --- | --- | +| session | `display-message -p -t '$ID'` の `#{session_windows}` / `#{session_attached}` と、解決した名前・ID | +| clients | `list-clients -t '$ID'` の `#{client_name}` と `#{t:client_activity}`。無ければ `(なし)` | +| panes | `list-panes -s -t '$ID'` の `#{window_index}.#{pane_index}`・`#{pane_current_command}`・`#{pane_pid}`・`#{pane_current_path}`。各行の下へ `pane_pid` の子孫のプロセスを深さで字下げして並べる | +| screen | 今のウィンドウの今の pane の見えている画面を `capture-pane -p -J -t '$ID'` で取り、末尾の空行を除いてから最後の `<行数>` 行を出す。`-n 0` ならこの節を出さない | + +- 子孫のプロセスは `ps -A -o pid= -o ppid= -o args=` を 1 回だけ取り、`awk` で辿る。Linux の + procps と macOS の `ps` で同じ引数が使える。`pgrep -P` は子しか出さず、`uv` の下の `python3` + のような孫が見えないため使わない。`&` で起動したものも出る +- `capture-pane -S -<行数>` は使わない。`-S` は履歴側の開始位置で、「履歴 N 行 + 画面全体」を + 返すため直近 N 行にならない + +### `kill`(落とす) + +| 状況 | 振る舞い | +| --- | --- | +| 対象が見つかった | `kill-session -t '$ID'`。attach 中・実行中を問わない。`KILL 名前` を出す | +| 対象が無い | 標準エラーへ出し、残りの対象へ進む。最後に終了コード 1 | +| `kill-session` が失敗した | 警告を出し、残りの対象へ進む。最後に終了コード 1 | +| 対象が自分のセッション・`-f` なし | 落とさずに理由を出し、残りへ進む。最後に終了コード 1 | +| 対象が自分のセッション・`-f` あり | **他の対象をすべて処理した後で**最後に落とす。自分のシェルに SIGHUP が届き、この処理自身も終わり得るため | +| `-n` | 落とさずに `KILL 名前 (dry-run)` を出す。`-f` と併せたときの自分のセッションも最後に出す | + +`kill` は確認を挟まない。非対話でも使うためで、確認はメニューの側が持つ。 + +### `menu` と `prefix S` + +`/etc/tmux.conf` の割り当ては次の 1 行である。 + +```tmux +bind-key S choose-tree -Zs -O name "run-shell -t \"%%%\" \"tmux-session menu -c #{q:client_name} #{q:session_id}\"" +``` + +- `choose-tree -Zs -O name` は、名前順のセッションの一覧を全画面で出す。tmux の既定の操作 + (`v` でプレビューの切り替え、`f` で絞り込み、`x` で 1 つ落とす、`t` で印を付けて `X` で + まとめて落とす)はそのまま使える +- template の `"%%%"` は選んだセッションの `=名前:` に置き換わる。`%%%` は `"` `\` `$` `;` `~` の + 前に `\` を補うため、二重引用の中でどんな名前でも壊れない。`'%%'` は `'` を含む名前で解析に + 失敗する。template では**最初の 1 つだけ**が置き換わるため、`%%%` は `-t` の 1 か所だけに書く +- `run-shell -t` で選んだセッションを対象にすることで、`#{q:session_id}` は選んだセッションの ID、 + `#{q:client_name}` は `prefix S` を押した端末の名前に、シェル向けの引用付きで展開される。 + `q:` を外すと `$1` などがシェルの位置引数として展開され、値が消える +- `menu` へ渡すのは名前ではなく ID である + +`menu` は次を出す。 + +```text +tmux display-menu -c 端末 -t '$ID' -T '#[align=centre]#{session_name}' … +``` + +題名は `-t` のセッションで書式が展開されるため、名前をコマンドへ埋め込まない。項目は次の 3 つ。 + +| 表示 | キー | 動かすもの | +| --- | --- | --- | +| 移る(他の端末を外す) | `a` | `run-shell -b "tmux-session go -c '端末' '$ID'"` | +| 中身を見る | `p` | `display-popup -c '端末' -E -w 90% -h 90% "tmux-session peek '$ID'; …; read -r _"`。出力の後に `[Enter で閉じる]` を出し、Enter で閉じる | +| 落とす | `k` | `confirm-before -t '端末' -p '<表示名> を落としますか? (y/n)' "run-shell -b \"tmux-session kill -f -c '端末' '$ID'\""` | + +- 項目のコマンドへ埋め込むのは、検査済みの端末名・ID と `<表示名>` だけである。引用は + `display-menu` の項目 → tmux のコマンド → `run-shell` のシェルの 3 段になる。tmux の二重引用の + 中の `$数字` は環境変数として展開されない(変数名は英字か `_` で始まる)ため、ID は `\` なしで + 書ける +- `<表示名>` は、名前が `[A-Za-z0-9._+@-]` だけでできていれば名前(devbase のセッション名 + `<ディレクトリ名>-<数字>` はここに入る)、それ以外の文字を含めば ID(例 `$3`)。確認の文の書式は + 押した端末の今のセッションで展開されるため、`#{session_name}` では選んだセッションを指せない +- `confirm-before` の端末の指定は `-t` である(`-c` は確認のキーを指す) +- **「落とす」は `-f` を付けて呼ぶ。** `confirm-before` の同意が自分のセッションを落とすことの + 確認を兼ねる。自分のセッションが落ちた端末は tmux の `detach-on-destroy` の既定(`on`)に + 従って外れる +- 「中身を見る」を `display-popup` にするのは、作業中の pane を覆わず、閉じれば元の画面に戻る + ためである。base に `less` は無く、`more` は出力が窓に収まるとすぐ終わって窓が閉じるため、 + `read` で Enter を待つ。popup の高さを超える出力は上が切れる。全体はシェルで `tmux-peek` を + 実行して読む +- 背景の `run-shell -b` から呼ばれた `go` / `kill` の知らせと誤りは、`display-message -c` で + 押した端末の状態行へ届く + +`prefix S` は tmux 3.6・3.7b の既定で割り当てが無く、既定の `s`(`choose-tree -Zs`)と並ぶ位置に +ある。`prefix s` は置き換えない(tmux に慣れた利用者の手の動きを変えるため)。 + +```mermaid +sequenceDiagram + participant U as 利用者の端末 + participant T as tmux サーバ + participant M as tmux-session menu + participant G as tmux-session go + U->>T: prefix S + T->>U: choose-tree(一覧とプレビュー) + U->>T: セッションを選んで Enter + T->>M: run-shell -t "%%%"
menu -c 端末 $ID + M->>M: -c と $ID を検査・解決 + M->>T: display-menu -c 端末 -t $ID + T->>U: メニュー + U->>T: a(移る) + T->>G: run-shell -b
go -c 端末 $ID + G->>T: list-clients -t $ID + G->>T: 端末以外を detach-client + G->>T: switch-client -c 端末 -t $ID + alt 失敗 + G->>T: display-message -l -c 端末 + end +``` + +### 終了コード(全サブコマンド共通) + +| 終了コード | 場合 | +| --- | --- | +| 0 | 成功。`-h` / `--help` | +| 1 | tmux が無い・サーバが無い・対象のセッションが無い・`-c` の端末が無い・実行元を特定できない・自分のセッションを `-f` なしで `kill` しようとした・tmux のコマンドが失敗した | +| 2 | 使い方の誤り(サブコマンドが無い・知らないサブコマンドとオプション・オプションの値が無い・セッションの数が合わない・`-n` が 0 以上の整数でない・`-c` の値の形が外れた・`menu` に `-c` が無い) | + +誤りは理由を標準エラーへ出す。終了コード 2 のときは `使い方は <呼ばれた名前> -h` を添える。 + +### 常に成り立つ条件 + +- 名前は完全一致で解決し、`$` + 数字は ID としてだけ解決する +- 解決した後の tmux の操作は ID で行い、名前をシェルや tmux のコマンドへ埋め込まない + (例外は安全な文字だけの `<表示名>`) +- `peek` は tmux の状態を変えない +- `go` は、実行元の端末と、対象以外のセッションに繋がっている端末を外さない。実行元が + 分からないときは何もしない +- `kill` は `-f` なしで自分のセッションを落とさず、`-f` ありでも自分のセッションは最後に落とす +- `tmux-first` / `tmux-clean` と、その短縮名 `tmux1` / `tmuxc` の振る舞いは変わらない + +## データ・設定 + +環境変数・設定ファイルは持たない。tmux の既定のソケット(`$TMUX_TMPDIR/tmux-/default`)の +サーバへ繋ぎ、`-S` / `-L` は受け取らない。 + +`prefix S` の割り当ては `/etc/tmux.conf` にあり、tmux は後から `~/.tmux.conf` を読むため、利用者が +`S` を別の操作に割り当てていればそちらが勝つ。その場合もコマンドは使える。 + +## 運用 + +- 変更は**イメージを建て直すまで反映されない**。`devbase build base --no-cache` で base を + 建て直し、使っている派生イメージ(いずれも `FROM devbase-base:latest`)も建て直し、稼働中の + コンテナは `devbase down` → `devbase up` で作り直す。`devbase up` だけでは反映されない +- 稼働中の tmux サーバへ `prefix S` だけを先に効かせるには `tmux source-file /etc/tmux.conf` を使う + (コマンドが `PATH` に無ければメニューは動かない) +- `containers/lfm` と `containers/snapshot` は base を継がないため入らない +- ホストの tmux で使うときは、利用者が devbase の checkout の `containers/base/tmux-session` を + 指す symlink を `~/.local/bin` へ 4 つ張り、`~/.tmux.conf` へ上の 1 行を足す。複写ではなく + symlink にすると `git pull` で更新が届く +- 動作を確かめてある tmux は、コンテナの 3.6(Ubuntu 26.04)とホストの 3.7b。使う機能 + (`choose-tree` の template・`display-menu`(3.0 以降)・`display-popup`(3.2 以降)・ + 書式の `q:`)は両方にある。`/bin/sh` は Ubuntu の dash と macOS の bash 3.2(POSIX モード)で + 確かめてある。WSL のホストと amd64 の建て直しは確かめていない(アーキテクチャに依存するものは + 無い) + +## テスト観点 + +`tests/containers/test_tmux_session.py`。テストごとに短い一時ディレクトリへ `TMUX_TMPDIR` を向け、 +`TMUX` を消した環境で tmux サーバを起動し、端末は `pty` から attach する。利用者の tmux サーバには +触れない。tmux が無い環境では tmux を使うテストを skip する。 + +- 移る + - tmux の外で `tmux-go devbase-1` を実行すると `devbase-1` に attach し、それまで繋がっていた + 端末は外れ、`devbase-10` の端末は残ること + - `-c` の実行元・プロンプトから打った実行元のどちらでも、対象の**他の**端末を外してから実行元を + 切り替え、実行元と別のセッションの端末は残ること。対象が実行元の今のセッションなら切り替えずに + 他の端末だけを外すこと + - 最終操作から 10 秒なら実行元と認め、11 秒なら何も外さず切り替えず終了コード 1 になること + - 端末を外せなかったときも残りの端末へ進んで切り替えること。切り替えに失敗したら終了コード 1 +- 調べる + - `&` で起動したものと子の下の孫を含め、4 節(session / clients / panes / screen)を出すこと + - `-n` の行数、`-n 0`、画面の行数を超える `-n`、端末が無いとき `(なし)` を出すこと + - 前後で `list-clients` と `list-sessions` が変わらないこと +- 落とす + - 端末が繋がりコマンドが動いているセッションを `-f` なしで落とし、`devbase-30` は残ること + - 無い対象・落とせない対象を飛ばして残りを落とし、終了コード 1 になること + - 自分のセッションは `-f` なしで残って終了コード 1、`-f` ありで他を処理した後に落ちること。 + `-c` の端末が見ているセッションも自分のセッションとして扱うこと + - `-n` は何も落とさず、`-n -f` の自分のセッションも最後に出すこと +- 名前と誤り + - 名前に空白・`'`・`"`・`$`・`;` を含んでも、3 つの操作が名前どおりのセッションに効くこと + - `$` + 数字が ID のセッションに効き、同じ文字列の名前へ落ちないこと + - 使い方の誤り(上の表の各場合)は終了コード 2、無いセッション・サーバが無い・`-c` の端末が + 無いときは終了コード 1 で、理由を標準エラーへ出すこと + - `tmux-session <サブコマンド>` と短縮名が同じ振る舞いをし、どちらでも `-h` が終了コード 0 で + あること +- `prefix S` + - `containers/base/tmux.conf` を読んだ tmux の `list-keys -T prefix` に `S` の割り当てがちょうど + 1 つあり、`choose-tree` を呼ぶこと(`test_tmux_conf.py`) + - 引数を書き出すだけの偽の `tmux-session` を `PATH` の先頭に置き、`pty` から `C-b S` → 選択 → + Enter を送ると、選んだセッションの ID と押した端末の名前が渡ること。特殊文字の名前でも同じこと + - 本物の `tmux-session` で、メニューの「移る」「中身を見る」「落とす」(確認の `y`)がそれぞれ + 効き、今いるセッションも同意すれば落ちること。背景の `run-shell` からの知らせが端末へ届くこと +- 配布 + - Dockerfile に `COPY --chmod=0755 tmux-session /usr/local/bin/tmux-session` が 1 行あり、 + 短縮名 3 つの `ln -sf tmux-session /usr/local/bin/<名前>` があること + - `containers/base/tmux-session` が `#!/bin/sh` で始まり実行権を持つこと + +`test_tmux_conf.py` の copy-mode の割り当ての比較は `-T copy-mode` / `-T copy-mode-vi` の行だけを +対象にし、`prefix S` の行が混ざっても崩れない。 + +CI はイメージを建てないため、次は建てたイメージで手で確かめる。 + +- `docker run --rm --entrypoint /bin/bash devbase-base:latest -c 'ls -l /usr/local/bin/tmux-*; tmux-go -h; echo exit=$?'` + で 4 つのコマンドがあり、`tmux-go -h` が終了コード 0 で終わること +- 建てた base の `shellcheck` で `containers/base/tmux-session` を既定の severity で検査すると、 + 指摘が 0 件であること +- 建て直した base のコンテナの tmux で、`prefix S` のメニューの 3 つの操作が効くこと + +CI の ShellCheck ジョブは runner の shellcheck で `tmux-first` / `tmux-clean` / `tmux-session` を +検査する。 + +## 関連リンク + +- [環境変数ガイド: セッションを名指しで扱う](../user/environment-variables.md#セッションを名指しで扱う) +- [コンテナ操作ガイド: tmux(ターミナル)の既定設定](../user/container-operations.md#tmuxターミナルの既定設定) +- [Kiro CLI 認証永続化と tmux コピー操作](kiro-auth-persistence-and-tmux-copy.md)(`/etc/tmux.conf` の他の既定) +- [base イメージの Bash の静的検査(shellcheck)](base-image-shellcheck.md) diff --git a/docs/user/environment-variables.md b/docs/user/environment-variables.md index 8098bc57..d00f93d9 100644 --- a/docs/user/environment-variables.md +++ b/docs/user/environment-variables.md @@ -580,6 +580,58 @@ tmux-clean -f # アタッチ中・実行中のセッションも削除する tmuxc # tmux-clean の短縮コマンド (/usr/local/bin の symlink) ``` +##### セッションを名指しで扱う + +`tmux-first` / `tmux-clean` は同じベース名のセッションをまとめて扱い、使用中の端末と実行中のセッションを守ります。1 つのセッションを狙って操作するときは、dev コンテナに同梱の `tmux-session` を使います。守りの既定は無く、指したセッションに強制的に効きます。 + +| コマンド | すること | +| --- | --- | +| `tmux-go <セッション>` | そのセッションへ移り、そのセッションに繋がっている**他の**端末を外す。tmux の外では attach し、tmux の中では今の端末を切り替える | +| `tmux-peek <セッション>` | attach せずに調べる。繋がっている端末と最終操作の時刻、pane ごとのコマンド・pid・作業ディレクトリ、pane のシェルの子孫のプロセス(`&` で起動したものも)、画面の直近 20 行を出す。tmux の状態は変えない | +| `tmux-kill <セッション>...` | attach 中・実行中を問わず落とす。無いセッションは飛ばして残りを続け、終了コード 1 で終わる | + +```bash +tmux-go devbase-3 # devbase-3 へ移る (devbase-30 には当たらない。名前は完全一致) +tmux-peek devbase-2 # 何が動いているかを見る +tmux-peek -n 50 devbase-2 # 画面を 50 行出す (-n 0 で出さない) +tmux-kill devbase-4 devbase-5 +tmux-kill -n devbase-4 # 落とさずに、落とす予定だけを出す +tmux-kill -f devbase-2 # 今いるセッションも落とす (-f が無ければ落とさない) +tmux-session go devbase-3 # 短縮名と同じ。tmux-session peek / kill も同様 +``` + +- **セッションは名前の完全一致か、ID(`$3` の形)で指します。** 名前が `$` と数字だけでできているセッションは、コマンドからは名前で指せません(ID として引くため)。下の `prefix S` の一覧からは選べます +- tmux の中で `tmux-go` を使うと、プロンプトへ打ち込んだ端末を実行元とみなします(`tmux-first` と同じ規則)。キー入力を伴わずに起動されて実行元を特定できないときは、何も外さず切り替えもせず、手で切り替えるコマンドを出して終了コード 1 で終わります + +tmux の中では **`prefix S`**(既定では `Ctrl+b` → `Shift+s`)で、セッションの一覧から選んで同じ 3 つを呼べます。一覧は tmux の `choose-tree` で、`v` でプレビューの切り替え、`f` で絞り込み、`x` で 1 つ落とす、`t` で印を付けて `X` でまとめて落とす、といった tmux の既定の操作も使えます。セッションを選んで `Enter` を押すとメニューが出ます。 + +| メニュー | キー | すること | +| --- | --- | --- | +| 移る(他の端末を外す) | `a` | `tmux-go` と同じ。今の端末をそのセッションへ切り替える | +| 中身を見る | `p` | `tmux-peek` の出力を浮いた窓に出す。`Enter` で閉じる | +| 落とす | `k` | 確認(`y/n`)を挟んで落とす。今いるセッションも、同意すれば落とす | + +`prefix S` の割り当ては `/etc/tmux.conf` にあります。`~/.tmux.conf` で `S` を別の操作に割り当てていれば、後から読むそちらが勝ちます(コマンドはそのまま使えます)。 + +**ホストの tmux で使う場合**は、devbase の checkout の中のファイルへ symlink を張り、`~/.tmux.conf` へ 1 行足します(devbase はホストのこれらのファイルへ書き込みません)。複写ではなく symlink にすると、devbase を `git pull` するだけで更新が届きます。`~/.local/bin` が `PATH` に入っている必要があります。 + +```bash +DEVBASE_DIR=~/devbase # devbase を clone した場所 +for n in tmux-session tmux-go tmux-peek tmux-kill; do + ln -sf "$DEVBASE_DIR/containers/base/tmux-session" ~/.local/bin/$n +done +``` + +```tmux +# ~/.tmux.conf に足す (反映は tmux source-file ~/.tmux.conf か tmux kill-server) +bind-key S choose-tree -Zs -O name "run-shell -t \"%%%\" \"tmux-session menu -c #{q:client_name} #{q:session_id}\"" +``` + +dev コンテナで使うには、base イメージの建て直し(`devbase build base --no-cache`)と、使っている派生イメージの建て直し、コンテナの作り直し(`devbase down` → `devbase up`)が要ります。 + +サブコマンドごとの引数・終了コード・`peek` の出力・`prefix S` のメニューの仕様は +[tmux のセッションを名指しで扱うコマンド(tmux-session)](../specifications/tmux-named-session.md) にあります。 + ## ソースファイル変更検出 devbase はソースファイル(`~/.aws/config` 等)のハッシュを `.env.sources.yml` で管理しています。 diff --git a/issues/PLAN69_tmux-named-session-design.md b/issues/old/PLAN69_tmux-named-session-design.md similarity index 100% rename from issues/PLAN69_tmux-named-session-design.md rename to issues/old/PLAN69_tmux-named-session-design.md diff --git a/issues/PLAN69_tmux-named-session.md b/issues/old/PLAN69_tmux-named-session.md similarity index 77% rename from issues/PLAN69_tmux-named-session.md rename to issues/old/PLAN69_tmux-named-session.md index 3a7f7f45..2e262be4 100644 --- a/issues/PLAN69_tmux-named-session.md +++ b/issues/old/PLAN69_tmux-named-session.md @@ -87,7 +87,46 @@ ## 実装計画 設計は [PLAN69_tmux-named-session-design.md](PLAN69_tmux-named-session-design.md)。 -**タスクへの分解は実装の持ち場で `/ndf:implementation-plan` が行う。** + +### タスク分解 + +機能(サブコマンド)単位で分け、各タスクは `tests/containers/test_tmux_session.py` の +失敗するテストから始める(テスト駆動)。テストは `TMUX_TMPDIR` を専用の短いディレクトリへ +向け、利用者の tmux サーバに触れない。 + +| # | タスク | 対象ファイル | 満たす受け入れ条件 | +| --- | --- | --- | --- | +| 1 | 骨組み: 呼ばれた名前での振り分け・`-h`・使い方の誤り(2)・tmux/サーバが無い(1)・セッションの解決(`$ID` と名前の完全一致) | `containers/base/tmux-session`、テスト | 11, 12 | +| 2 | `kill`(`-n` / `-f` / 自分のセッション / 無い対象を飛ばして続ける) | 同上 | 6, 7, 8, 9, 10 | +| 3 | `peek`(4 節・子孫のプロセス・状態を変えない) | 同上 | 4, 5, 10 | +| 4 | `go`(tmux の外の `attach -d`・中で `-c` / 実行元の特定・特定できないとき何もしない) | 同上 | 1, 2, 3, 10 | +| 5 | `menu` と `prefix S`(`choose-tree` の template → `menu` へ渡る値。偽の `tmux-session` で確かめる)。`test_tmux_conf.py` の copy-mode の比較を `-T copy-mode*` の行へ絞る | `tmux-session`、`tmux.conf`、`test_tmux_conf.py`、テスト | 13, 14(15 は検査の持ち場で手で確かめる) | +| 6 | 配布: Dockerfile の `COPY` と symlink、CI の ShellCheck の step | `Dockerfile`、`ci.yml`、テスト | 16 の静的な部分、18(16・17 の実イメージは検査の持ち場) | +| 7 | 文書: 利用者向け文書の小節と CHANGELOG | `docs/user/environment-variables.md`、`CHANGELOG.md` | 21 | + +- 受け入れ条件 19 は各タスクで `tmux-first` / `tmux-clean` を触らないことで守り、最後に + `git diff --stat` で確かめる。20 は最後に全体テストで確かめる +- 設計の「未確認」のうち `run-shell -b` からの `display-message -c` の到達は、タスク 5 で + ホストの tmux 3.7b(専用のソケット)で確かめ、届かなければ設計のとおり前面の出力へ変える +- イメージの建て直しとコンテナでの確認(15・16・17)は、この持ち場では行わず検査の持ち場へ回す + +### 実装で確かめたこと(2026-09-24、ホストの tmux 3.7b・専用のソケット) + +| 設計の未確認 | 結果 | +| --- | --- | +| メニューの引用の入れ子 | `prefix S` → 選択 → `a` / `p` / `k`→`y` を pty から送るテストで、`'`・`"`・`$`・`;` を含む名前でも選んだセッションに効いた。tmux の二重引用の中の `$3` は環境変数として展開されない(変数名は英字か `_` で始まる)ため、ID は `\` なしで埋め込める | +| 背景の `run-shell -b` からの `display-message -c` | 届いた(`test_menu_notifies_client_on_failure`)。知らせの出し先は設計のまま | +| runner の shellcheck の版 | 手元の shellcheck 0.11.0(`uvx --from shellcheck-py`)で `tmux-first` / `tmux-clean` / `tmux-session` とも既定の severity で 0 件。runner の版の結果は Pull Request の CI で見る | +| Ubuntu の `/bin/sh`(dash) | macOS の `/bin/dash` へ shebang を差し替えて `test_tmux_session.py` を走らせ、すべて通った | + +### リスクと対処 + +| リスク | 対処 | +| --- | --- | +| `menu` の引用の 3 段の入れ子で、名前の特殊文字が壊れる | 埋め込むのは検査済みの ID・端末名・安全な表示名だけにする(設計の `menu`)。偽の `tmux-session` の引数の書き出しで確かめる | +| macOS の `/bin/sh`(bash 3.2)と Ubuntu の `dash` の差 | POSIX の範囲で書き、テストはホストの `/bin/sh` で走らせる。shellcheck は `sh` として検査する | +| CI の runner の shellcheck の版で `tmux-first` / `tmux-clean` に指摘が出る | 直さずに止めて報告する(受け入れ条件 19 とぶつかるため) | +| 触る範囲は新設 1 ファイルが中心で、既存の構造は変えない | 実装の後の構造改善で足りる | ### 修正対象 diff --git a/tests/containers/test_tmux_conf.py b/tests/containers/test_tmux_conf.py index 27e61472..7f085d11 100644 --- a/tests/containers/test_tmux_conf.py +++ b/tests/containers/test_tmux_conf.py @@ -204,7 +204,9 @@ def test_conf_provides_windows_like_copy_bindings(): directives = [line.strip() for line in TMUX_CONF.read_text().splitlines() if line.strip() and not line.strip().startswith("#")] assert directives, "設定が 1 行も無い" - binding_directives = [line for line in directives if not line.startswith("set ")] + # prefix S の割り当て (PLAN69) は別のテストで見る。ここは copy-mode の割り当てだけを比べる。 + binding_directives = [line for line in directives + if re.search(r"-T copy-mode(-vi)? ", line)] assert binding_directives == [ "unbind-key -T copy-mode MouseDragEnd1Pane", "unbind-key -T copy-mode-vi MouseDragEnd1Pane", @@ -231,3 +233,34 @@ def test_hyperlinks_reach_the_outer_terminal(): hls = [line.strip() for line in capabilities.splitlines() if ": Hls:" in line] assert hls, "クライアントの能力表に Hls が無い" assert "[missing]" not in hls[0], f"Hls が定義されていない: {hls[0]}" + + +def _prefix_keys(config: Path) -> list[str]: + """``config`` を読ませた tmux の ``list-keys -T prefix`` の行。""" + socket = Path(tempfile.gettempdir()) / f"dvb69-{uuid.uuid4().hex[:8]}" + env = {k: v for k, v in os.environ.items() if k != "TMUX"} + base = ["tmux", "-S", str(socket)] + started = subprocess.run([*base, "-f", str(config), "new-session", "-d"], + capture_output=True, text=True, env=env) + assert started.returncode == 0, f"tmux の起動に失敗した: {started.stderr}" + try: + shown = subprocess.run([*base, "list-keys", "-T", "prefix"], + capture_output=True, text=True, env=env, check=True) + return shown.stdout.splitlines() + finally: + subprocess.run([*base, "kill-server"], capture_output=True, text=True, env=env) + with contextlib.suppress(FileNotFoundError): + socket.unlink() + + +@needs_tmux +def test_prefix_s_opens_session_chooser(): + """PLAN69 条件 13: prefix S の割り当てがちょうど 1 つあり、choose-tree を呼ぶ。 + + 既定の tmux には prefix S の割り当てが無い (3.6・3.7b で確認)。 + """ + bound = [line for line in _prefix_keys(TMUX_CONF) + if re.match(r"bind-key\s+(-r\s+)?-T prefix\s+S\s", line)] + assert len(bound) == 1, bound + assert "choose-tree" in bound[0] + assert "tmux-session menu" in bound[0] diff --git a/tests/containers/test_tmux_session.py b/tests/containers/test_tmux_session.py new file mode 100644 index 00000000..8b2c98cb --- /dev/null +++ b/tests/containers/test_tmux_session.py @@ -0,0 +1,1092 @@ +"""名指しで tmux のセッションを扱うコマンド ``tmux-session`` (PLAN69 / #234) + +``containers/base/tmux-session`` を実物の tmux に対して走らせ、``list-clients`` / +``list-sessions`` の変化で振る舞いを確かめる。 + +利用者の tmux サーバーには触れない。テストごとに ``$TMPDIR`` 直下へ短い名前の +ディレクトリを作り、``TMUX_TMPDIR`` をそこへ向けて ``TMUX`` を消した環境を 1 つ作る。 +テスト側の tmux の操作も ``tmux-session`` の実行も、すべてこの環境で行う +(``tmux-session`` は ``-S`` を受け取らず既定のソケットへ繋ぐため、両者が同じサーバーを +見るには環境をそろえるしかない)。サーバーは ``-f /dev/null`` (または対象の設定) で +起動し、利用者の ``~/.tmux.conf`` を読まない。 + +端末は ``pty.openpty`` で用意する。端末の出力はスレッドで読み続ける (読まないと +tmux のクライアントが書き込みで止まり、表示を確かめるテストにも要る)。 +""" + +from __future__ import annotations + +import contextlib +import json +import os +import pty +import re +import shutil +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +import pytest + +BASE_DIR = Path(__file__).resolve().parents[2] / "containers" / "base" +SCRIPT = BASE_DIR / "tmux-session" +TMUX_CONF = BASE_DIR / "tmux.conf" +DOCKERFILE = BASE_DIR / "Dockerfile" +SHORT_NAMES = ("tmux-go", "tmux-peek", "tmux-kill") + +needs_tmux = pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux が無い環境") + +# 名前に含まれても取り違えないことを確かめる文字 (受け入れ条件 10・14) +SPECIAL_NAMES = ["a b", "it's", 'q"x', "d$1", "s;x"] + + +def test_go_from_target_session_detaches_only_other_target_clients(tmp_path): + """現状固定: 移動先に既にいる実行元と、無関係なセッションの端末は残る。""" + state = tmp_path / "clients.json" + state.write_text(json.dumps({ + "/dev/pts/1": "$1", "/dev/pts/2": "$1", "/dev/pts/3": "$2", + })) + stub = tmp_path / "tmux" + stub.write_text(f"#!{sys.executable}\n" + ''' +import json +import sys +from pathlib import Path + +state = Path(__file__).with_name("clients.json") +clients = json.loads(state.read_text()) +command, *args = sys.argv[1:] + +def option(flag): + return args[args.index(flag) + 1] + +if command == "list-sessions": + print("$1 target\\n$2 unrelated") +elif command == "list-clients": + for client, session in clients.items(): + if "-t" not in args or session == option("-t"): + print(client) +elif command == "display-message": + if "-p" in args: + print(clients[option("-c")]) +elif command == "detach-client": + del clients[option("-t")] + state.write_text(json.dumps(clients)) +elif command == "switch-client": + clients[option("-c")] = option("-t") + state.write_text(json.dumps(clients)) +else: + raise SystemExit(f"unsupported tmux command: {command}") +''') + stub.chmod(0o755) + env = {k: v for k, v in os.environ.items() + if k not in ("TMUX", "TMUX_PANE", "ENV", "BASH_ENV")} + env["PATH"] = f"{tmp_path}:{os.environ.get('PATH', '/usr/bin:/bin')}" + + done = subprocess.run( + [str(SCRIPT), "go", "-c", "/dev/pts/1", "target"], + capture_output=True, text=True, env=env, timeout=30, + ) + + assert done.returncode == 0, done.stderr + assert done.stdout == "" + assert json.loads(state.read_text()) == { + "/dev/pts/1": "$1", "/dev/pts/3": "$2", + } + + +@pytest.mark.parametrize("idle_seconds, expected_returncode, expected_clients", [ + (10, 0, {"/dev/pts/1": "$2", "/dev/pts/3": "$3"}), + (11, 1, {"/dev/pts/1": "$1", "/dev/pts/2": "$2", "/dev/pts/3": "$3"}), +]) +def test_go_infers_client_at_activity_boundary( + tmp_path, idle_seconds, expected_returncode, expected_clients, +): + """現状固定: 最終操作から10秒なら移動し、11秒なら全接続を維持する。""" + state = tmp_path / "clients.json" + state.write_text(json.dumps({ + "/dev/pts/1": "$1", "/dev/pts/2": "$2", "/dev/pts/3": "$3", + })) + (tmp_path / "activity").write_text(str(1_000 - idle_seconds)) + stub = tmp_path / "tmux" + stub.write_text(f"#!{sys.executable}\n" + ''' +import json +import sys +from pathlib import Path + +state = Path(__file__).with_name("clients.json") +clients = json.loads(state.read_text()) +command, *args = sys.argv[1:] + +def option(flag): + return args[args.index(flag) + 1] + +if command == "list-sessions": + print("$1 source\\n$2 target\\n$3 unrelated") +elif command == "list-clients": + for client, session in clients.items(): + if "-t" not in args or session == option("-t"): + print(client) +elif command == "display-message": + if args[-1] == "#{client_activity}:#{client_name}": + activity = Path(__file__).with_name("activity").read_text() + print(f"{activity}:/dev/pts/1") + elif args[-1] == "#{session_id}": + print(clients[option("-c")] if "-c" in args else "$1") + else: + raise SystemExit(f"unsupported tmux format: {args[-1]}") +elif command == "detach-client": + del clients[option("-t")] + state.write_text(json.dumps(clients)) +elif command == "switch-client": + clients[option("-c")] = option("-t") + state.write_text(json.dumps(clients)) +else: + raise SystemExit(f"unsupported tmux command: {command}") +''') + stub.chmod(0o755) + date = tmp_path / "date" + date.write_text('#!/bin/sh\n[ "$1" = "+%s" ] || exit 1\nprintf "1000\\n"\n') + date.chmod(0o755) + env = {k: v for k, v in os.environ.items() + if k not in ("TMUX", "TMUX_PANE", "ENV", "BASH_ENV")} + env.update(PATH=f"{tmp_path}:{os.environ.get('PATH', '/usr/bin:/bin')}", + TMUX="/unused/socket,123,0", TMUX_PANE="%1") + + done = subprocess.run( + [str(SCRIPT), "go", "target"], + capture_output=True, text=True, env=env, timeout=30, + ) + + assert done.returncode == expected_returncode, done.stderr + assert json.loads(state.read_text()) == expected_clients + if idle_seconds == 11: + assert "実行元の端末を特定できない" in done.stderr + + +# go で detach-client / switch-client が失敗する経路を固定する。実物の tmux では特定の +# 操作だけを失敗させられないため、端末と接続先を JSON で持ち、fail に書いた操作だけを +# 非 0 にする tmux スタブを使う (fail は "detach-client <端末>" か "switch-client")。 +# -c のときの知らせ (display-message -l) は状態行へ出すだけなので何もしない。 +_GO_FAIL_STUB = f"#!{sys.executable}\n" + ''' +import json +import sys +from pathlib import Path + +state = Path(__file__).with_name("clients.json") +fail = Path(__file__).with_name("fail").read_text().strip() +clients = json.loads(state.read_text()) # {端末: セッション ID} +command, *args = sys.argv[1:] + + +def option(flag): + return args[args.index(flag) + 1] + + +if command == "list-sessions": + print("$1 source\\n$2 target\\n$3 unrelated") +elif command == "list-clients": + for client, session in clients.items(): + if "-t" not in args or session == option("-t"): + print(client) +elif command == "display-message": + if "-p" in args: + print(clients[option("-c")]) +elif command == "detach-client": + if fail == f"detach-client {option('-t')}": + sys.exit(1) + del clients[option("-t")] + state.write_text(json.dumps(clients)) +elif command == "switch-client": + if fail == "switch-client": + sys.exit(1) + clients[option("-c")] = option("-t") + state.write_text(json.dumps(clients)) +else: + raise SystemExit(f"unsupported tmux command: {command}") +''' + + +def _go_fail_env(tmp_path, fail): + """実行元を source、2 台を target、1 台を unrelated に繋ぎ、``fail`` の操作だけ失敗させる。""" + (tmp_path / "clients.json").write_text(json.dumps({ + "/dev/pts/1": "$1", "/dev/pts/2": "$2", "/dev/pts/3": "$2", + "/dev/pts/4": "$3", + })) + (tmp_path / "fail").write_text(fail) + stub = tmp_path / "tmux" + stub.write_text(_GO_FAIL_STUB) + stub.chmod(0o755) + env = {k: v for k, v in os.environ.items() + if k not in ("TMUX", "TMUX_PANE", "ENV", "BASH_ENV")} + env["PATH"] = f"{tmp_path}:{os.environ.get('PATH', '/usr/bin:/bin')}" + return env + + +def test_go_detach_failure_warns_but_still_switches(tmp_path): + """現状固定: 対象の他端末を外せなくても警告だけで続け、実行元は移り終了値は 0。""" + env = _go_fail_env(tmp_path, "detach-client /dev/pts/2") + + done = subprocess.run( + [str(SCRIPT), "go", "-c", "/dev/pts/1", "target"], + capture_output=True, text=True, env=env, timeout=30, + ) + + assert done.returncode == 0, done.stderr + assert done.stdout == "" + assert "/dev/pts/2" in done.stderr + assert json.loads((tmp_path / "clients.json").read_text()) == { + "/dev/pts/1": "$2", "/dev/pts/2": "$2", "/dev/pts/4": "$3", + } + + +def test_go_detach_failure_continues_to_remaining_client_and_earlier_session(tmp_path): + """現状固定: $1 の先頭端末を外せなくても、残りを外して $2 の実行元を移す。""" + env = _go_fail_env(tmp_path, "detach-client /dev/pts/2") + state = tmp_path / "clients.json" + state.write_text(json.dumps({ + "/dev/pts/2": "$1", "/dev/pts/3": "$1", "/dev/pts/1": "$2", + })) + (tmp_path / "tmux").write_text(_GO_FAIL_STUB.replace( + "$1 source\\n$2 target", "$1 target\\n$2 source", + )) + + done = subprocess.run( + [str(SCRIPT), "go", "-c", "/dev/pts/1", "target"], + capture_output=True, text=True, env=env, timeout=30, + ) + + assert done.returncode == 0, done.stderr + assert "/dev/pts/2" in done.stderr + assert json.loads(state.read_text()) == { + "/dev/pts/2": "$1", "/dev/pts/1": "$1", + } + + +def test_go_switch_failure_exits_one_after_detaching(tmp_path): + """現状固定: 切り替えに失敗すると終了値 1。対象の他端末は外した後で、実行元は残る。""" + env = _go_fail_env(tmp_path, "switch-client") + + done = subprocess.run( + [str(SCRIPT), "go", "-c", "/dev/pts/1", "target"], + capture_output=True, text=True, env=env, timeout=30, + ) + + assert done.returncode == 1, done.stderr + assert done.stdout == "" + assert "target" in done.stderr + assert json.loads((tmp_path / "clients.json").read_text()) == { + "/dev/pts/1": "$1", "/dev/pts/4": "$3", + } + + +def _wait(predicate, timeout: float = 10.0, interval: float = 0.05): + """``predicate()`` が真を返すまで待ち、その値を返す。待ち切れなければ失敗にする。""" + deadline = time.monotonic() + timeout + while True: + value = predicate() + if value: + return value + if time.monotonic() > deadline: + raise AssertionError(f"待ち切れなかった: {predicate}") + time.sleep(interval) + + +class Client: + """pty に繋いだ 1 つのプロセス (tmux のクライアント)。出力を読み続ける。""" + + def __init__(self, argv: list[str], env: dict[str, str]): + self.controller, terminal = pty.openpty() + self.tty = os.ttyname(terminal) + self.proc = subprocess.Popen(argv, stdin=terminal, stdout=terminal, + stderr=terminal, env=env) + os.close(terminal) + self._buf = bytearray() + self._lock = threading.Lock() + self._reader = threading.Thread(target=self._read, daemon=True) + self._reader.start() + + def _read(self) -> None: + while True: + try: + data = os.read(self.controller, 65536) + except OSError: + return + if not data: + return + with self._lock: + self._buf += data + + def output(self) -> str: + with self._lock: + return self._buf.decode("utf-8", "replace") + + def send(self, data: str) -> None: + os.write(self.controller, data.encode()) + + def close(self) -> None: + if self.proc.poll() is None: + self.proc.terminate() + with contextlib.suppress(subprocess.TimeoutExpired): + self.proc.wait(timeout=5) + with contextlib.suppress(OSError): + os.close(self.controller) + + +class TmuxEnv: + """テスト専用の tmux サーバーと、それを指す環境。""" + + def __init__(self, root: Path, conf: Path | None = None, fake: Path | None = None): + self.root = root + self.conf = conf or Path("/dev/null") + self.bin = root / "bin" + self.bin.mkdir() + for name in ("tmux-session", *SHORT_NAMES): + (self.bin / name).symlink_to(SCRIPT) + path = f"{self.bin}:{os.environ.get('PATH', '/usr/bin:/bin')}" + if fake is not None: + path = f"{fake}:{path}" + self.env = {k: v for k, v in os.environ.items() + if k not in ("TMUX", "TMUX_PANE", "ENV", "BASH_ENV")} + self.env.update(TMUX_TMPDIR=str(root), TERM="xterm-256color", + SHELL="/bin/sh", PATH=path, PS1="$ ", LANG="C.UTF-8") + self.clients: list[Client] = [] + + # --- tmux の操作 --- + + def tmux(self, *args: str, check: bool = True) -> subprocess.CompletedProcess: + done = subprocess.run(["tmux", *args], capture_output=True, text=True, + env=self.env) + if check: + assert done.returncode == 0, f"tmux {args} が失敗した: {done.stderr}" + return done + + def new(self, name: str, *cmd: str) -> str: + """セッションを作り、その ID を返す。最初の 1 つがサーバーを起動する。""" + self.tmux("-f", str(self.conf), "new-session", "-d", "-s", name, + "-x", "120", "-y", "40", *cmd) + return self.sid(name) + + def sessions(self) -> dict[str, str]: + """``{名前: ID}``。サーバーが無ければ空。""" + done = self.tmux("list-sessions", "-F", "#{session_id} #{session_name}", + check=False) + result = {} + for line in done.stdout.splitlines(): + sid, _, name = line.partition(" ") + result[name] = sid + return result + + def sid(self, name: str) -> str: + return self.sessions()[name] + + def clients_of(self, sid: str) -> set[str]: + done = self.tmux("list-clients", "-t", sid, "-F", "#{client_name}", check=False) + return set(done.stdout.split()) + + def all_clients(self) -> dict[str, str]: + """``{端末名: セッション ID}``""" + done = self.tmux("list-clients", "-F", "#{client_name} #{session_id}", check=False) + return dict(line.split(" ", 1) for line in done.stdout.splitlines()) + + def spawn(self, argv: list[str], env: dict[str, str] | None = None) -> Client: + client = Client(argv, env or self.env) + self.clients.append(client) + return client + + def attach(self, sid: str) -> Client: + """端末を 1 つ ``sid`` へ繋ぎ、繋がるまで待つ。""" + client = self.spawn(["tmux", "attach-session", "-t", sid]) + _wait(lambda: self.all_clients().get(client.tty) == sid) + return client + + def inside_env(self, sid: str) -> dict[str, str]: + """``sid`` の pane の中のシェルと同じ ``TMUX`` / ``TMUX_PANE`` を持つ環境。""" + shown = self.tmux("display-message", "-p", "-t", sid, + "#{socket_path},#{pid},0 #{pane_id}").stdout.strip() + tmux_var, pane = shown.split(" ") + return {**self.env, "TMUX": tmux_var, "TMUX_PANE": pane} + + # --- tmux-session の実行 --- + + def run(self, *argv: str, env: dict[str, str] | None = None) -> subprocess.CompletedProcess: + prog, *args = argv + return subprocess.run([str(self.bin / prog), *args], capture_output=True, + text=True, env=env or self.env, timeout=30) + + def close(self) -> None: + # needs_tmux の付かないテストも tm を使う。tmux が無い環境でも後片付けを終える。 + if shutil.which("tmux", path=self.env["PATH"]) is not None: + self.tmux("kill-server", check=False) + for client in self.clients: + client.close() + shutil.rmtree(self.root, ignore_errors=True) + + +def _short_root() -> Path: + # UNIX ソケットのパス長には OS の上限 (macOS で 104 バイト) があるため、pytest の + # 一時ディレクトリではなく $TMPDIR 直下の短い名前を使う。 + return Path(tempfile.mkdtemp(prefix="dvb69-", dir=tempfile.gettempdir())) + + +@pytest.fixture +def tm(): + env = TmuxEnv(_short_root()) + try: + yield env + finally: + env.close() + + +# --- 骨組み: 呼び出しの形と失敗の形 (受け入れ条件 11・12) --- + + +@pytest.mark.parametrize("argv", [("tmux-session", "-h"), ("tmux-session", "go", "-h"), + ("tmux-go", "-h"), ("tmux-peek", "--help"), + ("tmux-kill", "-h")]) +def test_help_exits_zero(tm, argv): + done = tm.run(*argv) + assert done.returncode == 0, done.stderr + assert "tmux-session" in done.stdout + + +@pytest.mark.parametrize("argv", [ + ("tmux-session",), # サブコマンドが無い + ("tmux-session", "nope", "x"), # 知らないサブコマンド + ("tmux-go", "--nope", "x"), # 知らないオプション + ("tmux-kill", "-x", "x"), + ("tmux-go",), # 引数が足りない + ("tmux-peek", "a", "b"), # 引数が多い + ("tmux-peek", "-n", "-1", "x"), # -n が 0 以上の整数でない + ("tmux-peek", "-n", "abc", "x"), + ("tmux-go", "-c", "/dev/pts/1;x", "x"), # -c の形が外れた + ("tmux-go", "-c", "", "x"), + ("tmux-session", "menu", "x"), # menu に -c が無い + ("tmux-go", "-c"), # -c の値が無い (末尾) + ("tmux-peek", "-n"), # -n の値が無い (末尾) + ("tmux-kill",), # kill にセッションが無い + ("tmux-peek", "-c", "/dev/pts/1", "x"), # peek は -c を受け取らない + ("tmux-session", "menu", "-c", "/dev/pts/1", "a", "b"), # menu にセッションが複数 +]) +def test_usage_errors_exit_two(tm, argv): + done = tm.run(*argv) + assert done.returncode == 2, (done.stdout, done.stderr) + assert done.stderr.strip(), "理由を標準エラーへ出す" + + +@needs_tmux +def test_no_server_exits_one(tm): + """条件 11: tmux のサーバーが無いときは 1。""" + for argv in (("tmux-go", "x"), ("tmux-peek", "x"), ("tmux-kill", "x")): + done = tm.run(*argv) + assert done.returncode == 1, (argv, done.stderr) + assert "サーバ" in done.stderr + + +@needs_tmux +def test_missing_session_exits_one(tm): + """条件 11: 無いセッションは 1。完全一致で探し、前方一致に落ちない。""" + tm.new("devbase-10") + for argv in (("tmux-go", "devbase-1"), ("tmux-peek", "devbase-1"), + ("tmux-kill", "devbase-1")): + done = tm.run(*argv) + assert done.returncode == 1, (argv, done.stderr) + assert "devbase-1" in done.stderr + assert "devbase-10" in tm.sessions() + + +@needs_tmux +def test_id_form_does_not_fall_back_to_name(tm): + """``$数字`` は ID としてだけ引く。同じ文字列の名前のセッションへ落ちない。""" + tm.new("$99") + done = tm.run("tmux-kill", "$99") + assert done.returncode == 1 + assert "$99" in tm.sessions() + + +@needs_tmux +def test_id_form_targets_session(tm): + sid = tm.new("devbase-5") + done = tm.run("tmux-kill", sid) + assert done.returncode == 0, done.stderr + assert "devbase-5" not in tm.sessions() + + +@needs_tmux +def test_unknown_client_exits_one(tm): + tm.new("devbase-1") + done = tm.run("tmux-go", "-c", "/dev/nope", "devbase-1") + assert done.returncode == 1 + assert "/dev/nope" in done.stderr + + +# --- 落とす (受け入れ条件 6〜10・12) --- + + +@needs_tmux +@pytest.mark.parametrize("argv", [("tmux-kill",), ("tmux-session", "kill")]) +def test_kill_ends_attached_busy_session_only(tm, argv): + """条件 6・12: 端末が繋がり、シェル以外が動いていても落とす。devbase-30 は残る。""" + sid = tm.new("devbase-3", "sleep", "300") + tm.new("devbase-30") + tm.attach(sid) + + done = tm.run(*argv, "devbase-3") + + assert done.returncode == 0, done.stderr + assert "KILL devbase-3" in done.stdout + assert set(tm.sessions()) == {"devbase-30"} + + +@needs_tmux +def test_kill_continues_past_missing_target(tm): + """条件 7: 無い対象を飛ばして残りを落とし、最後に 1。""" + for name in ("a", "c", "keep"): + tm.new(name) + + done = tm.run("tmux-kill", "a", "b", "c") + + assert done.returncode == 1 + assert "b" in done.stderr + assert set(tm.sessions()) == {"keep"} + + +@needs_tmux +def test_kill_refuses_own_session_without_force(tm): + """条件 8: 自分の pane があるセッションは -f が無ければ落とさない。""" + sid = tm.new("devbase-3") + tm.new("other") + inside = tm.inside_env(sid) + + done = tm.run("tmux-kill", "devbase-3", "other", env=inside) + + assert done.returncode == 1 + assert "-f" in done.stderr + assert set(tm.sessions()) == {"devbase-3"}, "他の対象は続けて落とす" + + +@needs_tmux +def test_kill_force_ends_own_session_last(tm): + """-f なら自分のセッションも落とす。ほかの対象を先に処理する。""" + sid = tm.new("devbase-3") + tm.new("other") + tm.new("keep") + inside = tm.inside_env(sid) + + done = tm.run("tmux-kill", "-f", "devbase-3", "other", env=inside) + + assert done.returncode == 0, done.stderr + lines = [line for line in done.stdout.splitlines() if line.startswith("KILL")] + assert lines == ["KILL other", "KILL devbase-3"] + assert set(tm.sessions()) == {"keep"} + + +# ``kill-session`` 自体が失敗する経路を固定する。実物の tmux では特定の削除だけを +# 失敗させられないため、セッション状態を JSON で持ち、指定した ID の削除だけを非 0 に +# する tmux スタブを使う。resolve / name_of は起動時に 1 度だけ取る list-sessions の +# 出力で引くので、スタブは list-sessions・display-message・kill-session を返せばよい。 +_KILL_FAIL_STUB = f"#!{sys.executable}\n" + ''' +import json +import sys +from pathlib import Path + +state = Path(__file__).with_name("sessions.json") +fail = Path(__file__).with_name("fail").read_text().strip() +sessions = json.loads(state.read_text()) # {id: name} +command, *args = sys.argv[1:] + + +def option(flag): + return args[args.index(flag) + 1] + + +if command == "list-sessions": + for sid, name in sessions.items(): + print(f"{sid} {name}") +elif command == "display-message": + # 自分の pane があるセッションの ID (TMUX_PANE から引く)。 + print(Path(__file__).with_name("self").read_text().strip()) +elif command == "kill-session": + target = option("-t") + if target == fail: + sys.exit(1) + sessions.pop(target, None) + state.write_text(json.dumps(sessions)) +else: + raise SystemExit(f"unsupported tmux command: {command}") +''' + + +def _kill_fail_env(tmp_path, sessions, fail_id, self_id=""): + """``kill-session`` が ``fail_id`` の削除だけ失敗する tmux スタブと環境を作る。""" + (tmp_path / "sessions.json").write_text(json.dumps(sessions)) + (tmp_path / "fail").write_text(fail_id) + (tmp_path / "self").write_text(self_id) + stub = tmp_path / "tmux" + stub.write_text(_KILL_FAIL_STUB) + stub.chmod(0o755) + env = {k: v for k, v in os.environ.items() + if k not in ("TMUX", "TMUX_PANE", "ENV", "BASH_ENV")} + env["PATH"] = f"{tmp_path}:{os.environ.get('PATH', '/usr/bin:/bin')}" + return env + + +def _remaining(tmp_path): + return set(json.loads((tmp_path / "sessions.json").read_text()).values()) + + +def test_kill_keeps_failed_target_but_kills_following_success(tmp_path): + """現状固定: 通常対象の削除が失敗しても終了値を失わず、後続の成功対象は落とす。 + + kill-session が fail を非 0 で返す do_kill の else 分岐を通す。失敗対象 (fail) は + 残り、後続の成功対象 (ok) は削除される。終了値は 1 になる。無関係な keep は残る。 + """ + env = _kill_fail_env( + tmp_path, {"$1": "fail", "$2": "ok", "$3": "keep"}, fail_id="$1") + + done = subprocess.run( + [str(SCRIPT), "kill", "fail", "ok"], + capture_output=True, text=True, env=env, timeout=30, + ) + + assert done.returncode == 1, done.stderr + assert "fail" in done.stderr + assert _remaining(tmp_path) == {"fail", "keep"} + + +def test_kill_force_self_last_keeps_self_on_failure_but_kills_others(tmp_path): + """現状固定: -f で最後に回す自己セッションの削除が失敗しても終了値を失わない。 + + TMUX / TMUX_PANE を与えると self ($1) が SELF_SID に解決され、-f なので LAST へ + 回る。ほかの対象 (other) を先に落とし、最後の自己の kill-session が失敗する。 + 自己は残るが other は削除され、終了値は 1 になる。無関係な keep は残る。 + """ + env = _kill_fail_env( + tmp_path, {"$1": "self", "$2": "other", "$3": "keep"}, + fail_id="$1", self_id="$1") + env.update(TMUX="/unused/socket,123,0", TMUX_PANE="%1") + + done = subprocess.run( + [str(SCRIPT), "kill", "-f", "self", "other"], + capture_output=True, text=True, env=env, timeout=30, + ) + + assert done.returncode == 1, done.stderr + assert _remaining(tmp_path) == {"self", "keep"} + + +@needs_tmux +def test_kill_dry_run_force_own_session_last_keeps_sessions(tm): + """現状固定: -n と -f を同時に与えると、自分のセッションも予定だけ出して残す。 + + do_kill の LAST ブロックの DRY=1 分岐を通す。FORCE=1 で自分のセッション + (devbase-3) は LAST に回り、DRY=1 なので落とさず ``(dry-run)`` を出す。 + ほかの対象 (other) を先に、自分を後に、どちらも予定表示だけになる。 + """ + sid = tm.new("devbase-3") + tm.new("other") + tm.new("keep") + inside = tm.inside_env(sid) + before = set(tm.sessions()) + + done = tm.run("tmux-kill", "-n", "-f", "devbase-3", "other", env=inside) + + assert done.returncode == 0, done.stderr + lines = [line for line in done.stdout.splitlines() if line.startswith("KILL")] + assert lines == ["KILL other (dry-run)", "KILL devbase-3 (dry-run)"] + assert set(tm.sessions()) == before + + +@needs_tmux +def test_kill_own_session_with_client_keeps_it_and_kills_others(tm): + """現状固定: -c で自分の端末を渡すと、そのセッションは -f 無しでは残す。 + + HAVE_CLIENT=1 のため知らせは端末の状態行へ出て標準出力は空になる。SELF_SID は + -c の端末が見ている home に解決され、-f が無いので home は残り rc=1。自分でない + keep は落ちる。 + """ + home = tm.new("home") + tm.new("keep") + me = tm.attach(home) + + done = tm.run("tmux-kill", "-c", me.tty, "home", "keep") + + assert done.returncode == 1 + assert done.stdout == "" + assert set(tm.sessions()) == {"home"} + + +@needs_tmux +def test_kill_dry_run_keeps_sessions(tm): + """条件 9: -n は落とさずに予定を出す。""" + tm.new("devbase-3") + before = tm.sessions() + + done = tm.run("tmux-kill", "-n", "devbase-3") + + assert done.returncode == 0, done.stderr + assert "KILL devbase-3 (dry-run)" in done.stdout + assert tm.sessions() == before + + +@needs_tmux +@pytest.mark.parametrize("name", SPECIAL_NAMES) +def test_kill_special_names(tm, name): + """条件 10: 名前どおりのセッションだけを落とす。""" + tm.new(name) + tm.new("keep") + done = tm.run("tmux-kill", name) + assert done.returncode == 0, done.stderr + assert set(tm.sessions()) == {"keep"} + + +# --- 調べる (受け入れ条件 4・5・10・12) --- + + +def _observe(tm: TmuxEnv) -> tuple[str, str]: + clients = tm.tmux("list-clients", "-F", "#{client_name} #{session_id}").stdout + sessions = tm.tmux("list-sessions", "-F", "#{session_id} #{session_name}").stdout + return clients, sessions + + +@needs_tmux +@pytest.mark.parametrize("argv", [("tmux-peek",), ("tmux-session", "peek")]) +def test_peek_shows_clients_panes_processes_and_screen(tm, argv): + """条件 4・5・12: 4 節を出し、tmux の状態を変えない。""" + sid = tm.new("devbase-2") + client = tm.attach(sid) + tm.tmux("send-keys", "-t", sid, + "echo peek-marker; sleep 300 & sh -c 'sleep 301; true'", "Enter") + _wait(lambda: "sleep 301" in subprocess.run( + ["ps", "-A", "-o", "args="], capture_output=True, text=True).stdout) + _wait(lambda: "peek-marker" in tm.tmux("capture-pane", "-p", "-t", sid).stdout) + before = _observe(tm) + + done = tm.run(*argv, "devbase-2") + + assert done.returncode == 0, done.stderr + out = done.stdout + assert re.search(r"^session\s+devbase-2 \(\$\d+\)", out, re.M), out + assert "clients" in out and client.tty in out + assert "panes" in out and "pid=" in out + assert "sleep 300" in out, "& で起動したものも子孫として出る" + assert "sleep 301" in out, "孫のプロセスも出る" + assert re.search(r"^screen", out, re.M) and "peek-marker" in out + assert _observe(tm) == before + + +@needs_tmux +def test_peek_screen_lines(tm): + """``-n`` で画面の行数を絞り、``-n 0`` で画面の節を出さない。""" + sid = tm.new("devbase-2") + tm.tmux("send-keys", "-t", sid, "for i in 1 2 3 4 5; do echo line-$i; done", "Enter") + _wait(lambda: "line-5" in tm.tmux("capture-pane", "-p", "-t", sid).stdout) + + three = tm.run("tmux-peek", "-n", "3", "devbase-2").stdout + screen = three.split("\nscreen", 1)[1].splitlines()[1:] + assert len(screen) == 3, screen + assert "line-5" in screen[1] + + none = tm.run("tmux-peek", "-n", "0", "devbase-2").stdout + assert "\nscreen" not in none + + +@needs_tmux +def test_peek_without_clients_and_n_beyond_screen(tm): + """端末が 0 件なら clients 節は (なし) の 1 行、``-n`` が画面の行数を超えたら先頭から出す。""" + sid = tm.new("devbase-2") + tm.tmux("send-keys", "-t", sid, "for i in 1 2; do echo line-$i; done", "Enter") + _wait(lambda: "line-2" in tm.tmux("capture-pane", "-p", "-t", sid).stdout) + + done = tm.run("tmux-peek", "-n", "50", "devbase-2") + + assert done.returncode == 0, done.stderr + out = done.stdout + assert re.search(r"^session\s.*attached 0$", out, re.M), out + clients = out.split("\nclients\n", 1)[1].split("\npanes\n", 1)[0].splitlines() + assert len(clients) == 1, clients + assert "/dev/" not in clients[0], clients + screen = out.split("\nscreen", 1)[1].splitlines()[1:] + assert any("line-1" in l for l in screen), screen + assert any("line-2" in l for l in screen), screen + assert screen[-1].strip(), screen + + +@needs_tmux +@pytest.mark.parametrize("name", SPECIAL_NAMES) +def test_peek_special_names(tm, name): + """条件 10""" + tm.new(name) + tm.new("other") + done = tm.run("tmux-peek", name) + assert done.returncode == 0, done.stderr + assert done.stdout.startswith(f"session {name} (") + + +# --- 移る (受け入れ条件 1〜3・10・12) --- + + +@needs_tmux +@pytest.mark.parametrize("argv", [("tmux-go",), ("tmux-session", "go")]) +def test_go_outside_attaches_and_detaches_others(tm, argv): + """条件 1・12: tmux の外。devbase-1 の端末は外れ、devbase-10 の端末は残る。""" + one = tm.new("devbase-1") + ten = tm.new("devbase-10") + old = tm.attach(one) + neighbour = tm.attach(ten) + + me = tm.spawn([str(tm.bin / argv[0]), *argv[1:], "devbase-1"]) + + _wait(lambda: tm.clients_of(one) == {me.tty}) + assert tm.clients_of(ten) == {neighbour.tty} + _wait(lambda: old.proc.poll() is not None) + + +@needs_tmux +def test_go_with_client_detaches_others_then_switches(tm): + """条件 2 (-c): 対象の他の端末だけを外し、実行元を切り替える。""" + target = tm.new("devbase-3") + home = tm.new("home") + other = tm.new("other") + me = tm.attach(home) + stale = tm.attach(target) + bystander = tm.attach(other) + + done = tm.run("tmux-go", "-c", me.tty, "devbase-3") + + assert done.returncode == 0, done.stderr + assert done.stdout == "", "-c のときは標準出力へ出さない" + _wait(lambda: tm.clients_of(target) == {me.tty}) + assert tm.clients_of(other) == {bystander.tty} + assert stale.tty not in tm.all_clients() + + +@needs_tmux +def test_go_from_prompt_switches_the_typing_client(tm): + """条件 2 (プロンプト): 打ち込んだ端末を実行元と認める。""" + target = tm.new("devbase-3") + home = tm.new("home") + me = tm.attach(home) + stale = tm.attach(target) + time.sleep(0.5) # シェルがプロンプトを出すまで + + me.send("tmux-go devbase-3\r") + + _wait(lambda: tm.clients_of(target) == {me.tty}) + assert stale.tty not in tm.all_clients() + + +@needs_tmux +def test_go_inside_without_known_client_does_nothing(tm): + """条件 3: 実行元を特定できない (最終操作が 10 秒より前) ときは何もしない。""" + target = tm.new("devbase-3") + home = tm.new("home") + tm.attach(home) + tm.attach(target) + time.sleep(11) + before = tm.all_clients() + + done = tm.run("tmux-go", "devbase-3", env=tm.inside_env(home)) + + assert done.returncode == 1 + assert "switch-client" in done.stderr + assert tm.all_clients() == before + + +@needs_tmux +@pytest.mark.parametrize("name", SPECIAL_NAMES) +def test_go_special_names(tm, name): + """条件 10""" + sid = tm.new(name) + tm.new("other") + old = tm.attach(sid) + + me = tm.spawn([str(tm.bin / "tmux-go"), name]) + + _wait(lambda: tm.clients_of(sid) == {me.tty}) + _wait(lambda: old.proc.poll() is not None) + + +# --- tmux の中の UI (受け入れ条件 14・15) --- + + +def _open_tree(tm: TmuxEnv, me: Client, attempts: int = 5) -> None: + """``prefix S`` で一覧 (tree-mode) を出す。 + + 繋いだ直後のクライアントは端末への問い合わせの応答を待っている間の入力を捨てる + ことがある (CI で 10 秒待っても一覧が出なかった)。一覧が出なければ送り直す。 + """ + def in_tree() -> bool: + return tm.tmux("display-message", "-p", "-c", me.tty, + "#{pane_mode}").stdout.strip() == "tree-mode" + + for _ in range(attempts - 1): + me.send("\x02S") + with contextlib.suppress(AssertionError): + _wait(in_tree, timeout=2.0) + return + me.send("\x02S") + _wait(in_tree) + + +def _open_menu(tm: TmuxEnv, me: Client) -> None: + """``prefix S`` で一覧を出し、1 つ上のセッションを選ぶ。""" + _open_tree(tm, me) + me.send("\x1b[A") + time.sleep(0.2) + me.send("\r") + + +@pytest.fixture +def fake_tm(): + """引数を書き出すだけの偽の ``tmux-session`` を PATH の先頭に置いた環境。""" + root = _short_root() + fake = root / "fake" + fake.mkdir() + record = root / "args" + stub = fake / "tmux-session" + stub.write_text(f"#!/bin/sh\nprintf '%s\\n' \"$@\" > '{record}'\n") + stub.chmod(0o755) + env = TmuxEnv(root, conf=TMUX_CONF, fake=fake) + env.record = record + try: + yield env + finally: + env.close() + + +@needs_tmux +@pytest.mark.parametrize("name", SPECIAL_NAMES) +def test_prefix_s_passes_selected_id_and_client(fake_tm, name): + """条件 14: 選んだセッションの ID と、押した端末の名前が menu へ渡る。 + + 一覧は名前順 (``-O name``) で、カーソルは今のセッション ``zz-home`` にある。 + 1 つ上が選ぶセッションになる。 + """ + tm = fake_tm + target = tm.new(name) + home = tm.new("zz-home") + me = tm.attach(home) + + _open_menu(tm, me) + + _wait(lambda: tm.record.exists() and tm.record.read_text()) + assert tm.record.read_text().splitlines() == ["menu", "-c", me.tty, target] + + +@pytest.fixture +def ui_tm(): + env = TmuxEnv(_short_root(), conf=TMUX_CONF) + try: + yield env + finally: + env.close() + + +@needs_tmux +@pytest.mark.parametrize("name", ["devbase-3", "it's"]) +def test_menu_go_detaches_others_and_switches(ui_tm, name): + """条件 15 (移る): メニューの ``a`` が go を呼ぶ。""" + tm = ui_tm + target = tm.new(name) + home = tm.new("zz-home") + me = tm.attach(home) + stale = tm.attach(target) + + _open_menu(tm, me) + _wait(lambda: "移る" in me.output()) + me.send("a") + + _wait(lambda: tm.clients_of(target) == {me.tty}) + assert stale.tty not in tm.all_clients() + + +@needs_tmux +@pytest.mark.parametrize("name", ["devbase-3", 'q"x;$1']) +def test_menu_kill_asks_then_kills(ui_tm, name): + """条件 15 (落とす): 確認を挟み、y で落とす。""" + tm = ui_tm + tm.new(name) + home = tm.new("zz-home") + me = tm.attach(home) + + _open_menu(tm, me) + _wait(lambda: "落とす" in me.output()) + me.send("k") + _wait(lambda: "(y/n)" in me.output()) + assert name in tm.sessions(), "確認の前には落とさない" + me.send("y") + + _wait(lambda: name not in tm.sessions()) + assert "zz-home" in tm.sessions() + + +@needs_tmux +def test_menu_kill_own_session(ui_tm): + """メニューの「落とす」は -f 付きで、今いるセッションも同意で落とせる。""" + tm = ui_tm + tm.new("keep") + home = tm.new("zz-home") + me = tm.attach(home) + + _open_tree(tm, me) + me.send("\r") # カーソルは今のセッション + _wait(lambda: "落とす" in me.output()) + me.send("k") + _wait(lambda: "(y/n)" in me.output()) + me.send("y") + + _wait(lambda: "zz-home" not in tm.sessions()) + assert "keep" in tm.sessions() + + +@needs_tmux +def test_menu_peek_opens_popup(ui_tm): + """条件 15 (中身を見る): popup に peek の出力が出て、Enter で閉じる。""" + tm = ui_tm + target = tm.new("devbase-3") + tm.tmux("send-keys", "-t", target, "echo popup-marker", "Enter") + home = tm.new("zz-home") + me = tm.attach(home) + + _open_menu(tm, me) + _wait(lambda: "中身を見る" in me.output()) + me.send("p") + + _wait(lambda: "popup-marker" in me.output() and "Enter" in me.output()) + me.send("\r") + assert "devbase-3" in tm.sessions(), "見るだけで落とさない" + + +@needs_tmux +def test_menu_notifies_client_on_failure(ui_tm): + """背景の run-shell から ``display-message -c`` で端末へ知らせが届く。""" + tm = ui_tm + tm.new("zz-home") + me = tm.attach(tm.sid("zz-home")) + + tm.tmux("run-shell", "-b", f"tmux-go -c {me.tty} no-such-session") + + _wait(lambda: "no-such-session" in me.output()) + + +# --- 配布 (受け入れ条件 16 のうちビルドの前に分かる部分) --- + + +def test_dockerfile_installs_tmux_session(): + lines = DOCKERFILE.read_text().splitlines() + copies = [line for line in lines if line.startswith("COPY") and "tmux-session" in line] + assert copies == ["COPY --chmod=0755 tmux-session /usr/local/bin/tmux-session"] + + +def test_dockerfile_links_short_names(): + text = DOCKERFILE.read_text() + for name in SHORT_NAMES: + assert re.search(rf"ln -sf tmux-session /usr/local/bin/{name}\b", text), name + + +def test_script_is_executable_posix_sh(): + assert SCRIPT.read_text().startswith("#!/bin/sh\n") + assert os.access(SCRIPT, os.X_OK)