git のブランチ操作だけを扱う TUI「bui」を作った

git のブランチ操作だけを扱うターミナル UI、bui を作りました。
名前は「branch UI」の略で、Rust と ratatui で書いています。

git の TUI だと lazygit みたいな定番がすでにあるのですが、ステージングもコミットもリベースも全部入りで、正直自分がやりたいのはブランチの切り替えと掃除くらいなんだよなと思っていました。
なので bui は最初から「ブランチのことしかやらない」と決めて作っています。

あと、ratatui を使ってみたかったのも大きなモチベーションのひとつでした。
Rust で書けるのと、TUI としてサクサク動く操作性(レスポンス)の良さが魅力です。

できることはざっくり下記

ちなみにコミットは全部 Claude Code と一緒に作っています。

やらないことを先に決めた

作り始める前にとりあえず仕様書を書いて、やらないことをはっきりさせておきました。
コミットやステージング、merge / rebase / cherry-pick、マウス操作、コマンドパレット、PR との連携、コミットグラフあたりは全部スコープ外です。

機能には A1(ローカルブランチ一覧)とか B4(ブランチ削除)みたいなコードを振って、v0.1 / v0.2 / v0.3 と段階を切って作っていきました。

設計で決めたこと

git の操作は libgit2(git2 クレート)を使わずに、素直に git コマンドを呼ぶ形にしています。
ブランチ操作くらいなら機械可読な出力で十分ですし、git の設定やフック、認証ヘルパーもそのまま効きます。ネイティブのビルド依存がなくなるのも楽。

ブランチ一覧は git branch の人間向けの出力はパースせずに、for-each-ref のフォーマット指定で取っています。

HEAD · name · sha · 相対日付 · upstream · upstream の ahead/behind · 件名

この7フィールドを \x1f で区切って並べていて、コミットの件名だけ一番後ろに置いています。件名に変な文字が入っていても、後ろのフィールドが壊れないようにするためです。
upstream との ahead/behind もこの1回で取れるので、ブランチごとに git を叩き直さずに済んでいます。

非同期処理は tokio を入れずに std::thread と mpsc だけで組みました。やっていることは git のプロセスをいくつか起動するだけなので、async/await を持ち込むほどではないなと。
キー入力、描画用のティック、git の実行結果を全部1本のチャネルに流して、状態はイベントを受けたときにだけ変わるようにしています。fetch や push は1つずつしか走らせず、実行中に f や p を押しても無視します(キューに積むより状態が単純なので)。

テストは tempfile で一時ディレクトリを作って、本物の git init をしたリポジトリに対して実行しています。git はモックせず、パーサーは実際の出力で確かめる方針です。いまは単体テストと結合テストあわせて 165 本。

作りながら引っかかったところ

作りながら直したところは以下4点

  1. 確認ダイアログ、何を押せばいいの?
  2. pull のエラー、長すぎない?
  3. push が upstream の名前違いで落ちる
  4. チェックアウトしたのに差分が変わらない

1. 確認ダイアログ、何を押せばいいの?

確認ダイアログが最初は5行の高さしかなくて、[y]es / [n]o のヒント行が下の枠線に隠れていました。画面を見ても何を押せばいいのか分からないダイアログになっていたやつです。
8行に広げて Yes / No をボタンとして描画して、矢印キーでフォーカスを動かせるようにしました。破壊的な操作のときは最初のフォーカスを No にしてあるので、うっかり Enter を押してもキャンセルされます。

2. pull のエラー、長すぎない?

git pull のエラーをそのまま出していたら、upstream がないブランチで

error: git pull failed: There is no tracking information for the current branch.

と出たうえに、git が続けて出す複数行のアドバイスが1行しかないステータスバーで途中で切れていました。
エラーは stderr の最初の意味のある1行だけを出すようにして、upstream がないときは「no upstream — press u to set one」と bui 側のキーを案内するようにしています。

3. push が upstream の名前違いで落ちる

push は最初、upstream がなくて失敗したら --set-upstream origin HEAD で1回だけリトライする作りでした。
ところが origin/main を upstream として引き継いだ temp1 みたいな、名前の違うリモートブランチを追跡しているブランチだと、素の git push が upstream branch ... does not match で落ちます。
結局 push は常に git push -u origin HEAD で打つことにして、upstream がない場合も名前がずれている場合も1回で済むようにしました。分岐が3つあったロジックが1つになっています。

4. チェックアウトしたのに差分が変わらない

差分ペインでは、ブランチ A にカーソルを置いて v で差分を開いて、そのまま Enter で A をチェックアウトしても「A vs main」の差分が残り続けていました。
キャッシュを比較対象のブランチ名だけで見ていたのが原因で、比較元と比較対象の組 (target, base) で見るように直しています。ついでに pull や fetch で HEAD が動いたときも再計算するようにしました。

これから

そんなこんなで、v0.3 までのロードマップに載せた機能はひととおり入りました。

ただ worktree の行で Enter を押しても、TUI からは親のシェルのディレクトリを変えられないので、いまはステータスバーに cd <path> を出すだけです。
bui が移動先のパスをファイルに書いて、シェル関数が終了後にそれを読んで cd する、というラッパーの設計までは仕様書に書いてあります。
crates.io への公開もまだなので、このあたりが次のロードマップです。


🤖 Generated with Claude Code