I made bui, a TUI for git branch operations only

I made bui, a terminal UI that handles git branch operations only.
The name is short for “branch UI”. It is written in Rust with ratatui.

Git TUIs like lazygit already exist, but they come with staging, committing, rebasing, and everything else, when all I wanted was to switch branches and clean them up.
So from the start, bui was decided to be about branches and nothing else.

Wanting to use ratatui was another big motivation.
I liked that I could write it in Rust, and that TUIs built with it feel snappy and responsive.

Roughly, here is what it can do:

By the way, every commit was made together with Claude Code.

Deciding what not to do first

Before writing code, I wrote a spec and spelled out what bui would not do.
Commits and staging, merge / rebase / cherry-pick, mouse support, a command palette, PR integration, and a commit graph are all out of scope.

Each feature got a code like A1 (list local branches) or B4 (delete a branch), and I implemented them in stages: v0.1, v0.2, and v0.3.

Design decisions

For git operations, I shell out to the git command instead of using libgit2 (the git2 crate).
Machine-readable output is enough for branch operations, and the user’s git config, hooks, and credential helpers just work. There are no native build dependencies, either.

The branch list does not parse the human-readable output of git branch. It comes from for-each-ref with a format string.

HEAD · name · sha · relative date · upstream · upstream ahead/behind · subject

These seven fields are joined with \x1f, and the commit subject goes last, so an odd character in a subject cannot break the fields after it.
The ahead/behind against upstream comes from the same single call, so there is no extra git invocation per branch.

For concurrency I used only std::thread and mpsc, without tokio. All bui does is spawn a few git processes, so async/await brings little benefit.
Key input, render ticks, and git results all flow into one channel, and app state changes only when an event arrives. Only one fetch or push runs at a time, and pressing f or p while one is running is ignored. Not queueing keeps the state simple and the order predictable.

Git operations sit behind a Repo trait. Tests create a temporary directory with tempfile, run a real git init, and operate on that. Git is not mocked, and the parsers are verified against real output. There are 165 unit and integration tests right now.

What tripped me up along the way

Here are the four things I fixed while building it:

  1. The confirm dialog: what am I supposed to press?
  2. Isn’t the pull error a bit long?
  3. Push fails on a mismatched upstream name
  4. The diff doesn’t change after checkout

1. The confirm dialog: what am I supposed to press?

The confirm dialog was only five rows tall at first, and the [y]es / [n]o hint line was hidden behind the bottom border, so nothing on screen told you what to press.
I grew it to eight rows, drew Yes / No as buttons, and made focus movable with the arrow keys. For destructive operations the initial focus is No, so an accidental Enter cancels.

2. Isn’t the pull error a bit long?

git pull errors were shown as-is. On a branch without an upstream, it printed

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

and git’s multi-line advice got cut off in the status bar, which is one row tall.
Now only the first meaningful line of stderr is shown, and when there is no upstream, it says “no upstream — press u to set one” to point at bui’s own key.

3. Push fails on a mismatched upstream name

Push originally retried once with --set-upstream origin HEAD when it failed for lack of an upstream.
But a branch tracking a differently named remote branch, like a temp1 that inherited origin/main as its upstream, makes plain git push fail with upstream branch ... does not match.
In the end, push always runs git push -u origin HEAD, so a missing upstream and a mismatched one are both handled in one call. Three branches of logic became one.

4. The diff doesn’t change after checkout

In the diff pane, if you put the cursor on branch A, opened the diff with v, and then pressed Enter to check out A, the pane kept showing the “A vs main” diff.
The cache was keyed on the target branch name only. I changed it to key on the (target, base) pair, and it also recomputes when pull or fetch moves HEAD.

What’s next

With that, everything on the roadmap through v0.3 is in.

Still, a TUI cannot change its parent shell’s directory, so pressing Enter on a worktree row currently just shows cd <path> in the status bar.
The spec already describes a wrapper: bui writes the target path to a file passed through an environment variable, and a shell function reads it after exit and runs cd. bui is not on crates.io yet either, so those are next on the roadmap.


🤖 Generated with Claude Code