Migrating from Powerline-Go to Starship
I’ve been using powerline-go as my shell prompt for years. It’s a Go binary that renders a nice powerline-style prompt with segments for username, hostname, current directory, git status, and more. It works great, but I’ve been on a bit of a dotfiles cleanup kick and wanted to see if Starship could replace it.
Starship is a Rust-based prompt that’s become the de facto standard. It’s fast, cross-shell, and highly configurable through a single TOML file. The question was whether I could make it look identical to my powerline-go setup.
The Easy Part
Getting a basic Starship config that roughly matches powerline-go is straightforward. Starship has built-in modules for everything powerline-go shows: username, hostname, directory, git branch, git status. You can set background and foreground colors using 256-color codes, and the powerline arrow characters work in format strings.
[username]
show_always = true
style_user = "bg:240 fg:250"
format = "[ $user ]($style)"
Within an hour I had something that looked close. The colors matched, the segments were in the right order, and the basic git info was there.
The Hard Part
The devil is in the details. Powerline-go does several things that Starship’s built-in modules can’t replicate:
Path rendering. Powerline-go splits the current directory into segments. The first component (usually ~) gets a blue background, while the remaining directories get a darker grey background with thin powerline separators between them. Starship’s [directory] module renders the whole path as one block. There’s no option to split it up or use different colors for different components.
Git status segments. In powerline-go, each git status type gets its own colored segment with proper powerline arrows between them: staged files on dark green (bg:22), modified on orange (bg:130), untracked on dark red (bg:52), conflicted on bright red (bg:9), stashed on dark blue (bg:20). The arrows transition smoothly from one color to the next. Starship’s [git_status] module renders everything in one block. I tried using separate background colors per indicator, but the arrows between segments created ugly color artifacts since Starship doesn’t know which segments will be present at render time.
Clean vs dirty branch. Powerline-go shows the branch name on a green background when the repo is clean and pink when it’s dirty. Starship’s [git_branch] module doesn’t have this concept.
Custom Scripts to the Rescue
The solution was to replace Starship’s built-in modules with custom shell scripts that output raw ANSI escape codes. Starship’s [custom] modules let you run a command and insert its output into the prompt. If you set format = "$output", the raw terminal escape codes pass straight through.
I wrote two scripts:
path.sh handles the directory display. It splits $PWD into components, shows the first on blue, transitions with a powerline arrow to grey, then renders the remaining components with thin separators between them.
gitstatus.sh handles the entire git display. It runs git status --porcelain=v2 --branch to get branch info, ahead/behind counts, and file status in a single command. Then it builds the output with proper ANSI-coded colored segments and powerline arrows that transition correctly from one color to the next. Since the script knows which segments are present, it can calculate the arrow colors dynamically. Something Starship’s static format strings can’t do.
The clean/dirty branch coloring was the easiest part of the script. Just check if any counters are non-zero and pick green (bg:148) or pink (bg:161).
UTF-8 Gotcha
One thing that tripped me up: getting the powerline arrow character (U+E0B0) right in bash. I initially used $'\xee\x80\xb0' which seemed correct but rendered as diamonds. Turns out the proper UTF-8 encoding for U+E0B0 is \xee\x82\xb0, not \xee\x80\xb0. The middle byte is 0x82. I only caught this by hex-dumping Starship’s own output and comparing it byte-for-byte with my script’s output.
Performance
The whole point of switching was to modernize, so I wanted to make sure I wasn’t paying a performance penalty. Here’s the initial benchmark with bash scripts:
| Prompt | Median |
|---|---|
| powerline-go | 58ms |
| starship + bash scripts | 59ms |
Essentially identical. The bash scripts add overhead compared to Starship’s built-in Rust modules (which clock in around 13ms), but the result is on par with the Go binary it replaces. Most of the time is spent spawning bash and running git status.
Going Further: Rewriting in Rust
The bash scripts worked, but I couldn’t stop thinking about the overhead. Each prompt render spawns bash processes and shells out to git. What if the custom scripts were a compiled Rust binary using libgit2 instead?
I wrote a small Rust binary called starship-segments with subcommands for each segment type: path, git, and tmux-title. The path command is just string manipulation. The git command uses the git2 crate to read repository state directly through libgit2, avoiding all subprocess overhead. No git status, no git branch, no git stash list. Just library calls.
The result:
| Prompt | Median |
|---|---|
| starship + Rust binary | 36ms |
| starship + bash scripts | 59ms |
| powerline-go | 58ms |
36 milliseconds. About 40% faster than both the bash version and powerline-go. (Later I shaved another ~6ms off by eliminating redundant git rev-parse subprocess spawns from the Starship config — see below.) The Rust git subcommand alone runs in 25ms compared to 52ms for the bash script. Eliminating subprocess spawning and using libgit2 directly makes a real difference.
The binary is about 2MB after LTO and strip. Not nothing, but it’s a static binary with libgit2 linked in. It lives alongside the starship config and gets symlinked into place by stow.
Bonus: Faster Tmux Window Titles
Once the Rust binary existed, I realized I had the same subprocess problem elsewhere. My tmux setup automatically sets window titles based on the current directory: a house emoji for home, a folder emoji for regular directories, and a git branch icon with the repo name and branch for git repos. The shell hook that generates these titles was spawning four git subprocesses on every prompt: git rev-parse --git-dir, git branch --show-current, git rev-parse --show-toplevel, and git status --porcelain. That’s 30-60ms of overhead every time you hit enter.
Since starship-segments already had all the git machinery via libgit2, I added a tmux-title subcommand. It outputs tmux-formatted strings directly: #[fg=colour39] for a cyan branch icon on clean repos, #[fg=colour67] for muted blue on dirty ones, with a pencil indicator for uncommitted changes. The entire shell function went from 25 lines of git commands to a single binary call with a fallback.
Squeezing Out the Last Milliseconds
Even after the Rust rewrite, I noticed two remaining git rev-parse subprocesses hiding in the Starship config. The custom.gitstatus module had when = "git rev-parse --is-inside-work-tree" and custom.dir_end (which renders the closing arrow outside git repos) had the negated version. Both fire on every prompt, each costing ~3ms.
The fix was simple: since the Rust binary already calls Repository::discover() via libgit2, I made it output the closing arrow itself when it’s not in a repo. Now gitstatus runs unconditionally with when = "true" and dir_end is gone entirely. That’s ~6ms saved per prompt with zero visual change.
While I was at it, I applied the same caching pattern to two shell startup costs. Both atuin init zsh (~3.4ms) and vivid generate (~1.4ms) produce static output that only changes when the binary is updated, so I cache their output to ~/.cache/zsh/ and regenerate only when the binary is newer than the cache file:
if command -v atuin >/dev/null 2>&1; then
local cache="$SHELL_CACHE_DIR/atuin-init.zsh"
local atuin_bin="${commands[atuin]}"
if [[ ! -f "$cache" || "$atuin_bin" -nt "$cache" ]]; then
atuin init zsh --disable-up-arrow --disable-ctrl-r > "$cache"
fi
source "$cache"
fi
The cached reads come in at ~0.3ms, saving about 4ms total on shell startup.
Was It Worth It
Honestly, the built-in Starship config with some compromises would have been fine for most people. But I wanted an exact visual match to powerline-go, and the custom approach got me there. Then the Rust rewrite made it faster than the original.
The config and source are in my dotfiles if anyone wants to take a look.
The real win is that Starship is actively maintained, works across shells, and the TOML config is a lot easier to reason about than the powerline-go command line flags I had scattered across my zshrc. And now it’s faster too.