all repos — site @ 6c12dffeb312ef15b185e06a422435114f8f4cce

source for my site, found at icyphox.sh

pages/blog/go-shell-prompt.md (view raw)

  1---
  2template:
  3slug: go-shell-prompt
  4title: Writing a shell prompt in Go
  5subtitle: Kinda faster than bash
  6date: 2021-08-12
  7---
  8
  9For context, my bash prompt was previously [written in, well,
 10bash](https://git.icyphox.sh/dotfiles/tree/bash/.bashrc.d/99-prompt.bash?id=d7b391845abc7e97f2b1b96c34b4b1789b2ab541).
 11It used to call out to `git` for getting the branch and worktree status
 12info. Parsing the output of `git status` and all that. It was ok, but I
 13wanted something ... cleaner.
 14
 15I chose Go, despite having written
 16[nicy](https://github.com/icyphox/nicy) in Nim[^1]; I'm in a Go-phase right
 17now, just like I was in a Nim-phase back in 2018. Anyway, let's cut to
 18the chase.
 19
 20[^1]: It's a prompt "framework" thing that I actually only used for a
 21      month or so.
 22
 23## the basics
 24
 25The current working directory is the bare minimum in a prompt. I prefer
 26having it shortened; for example: `/home/icy/docs/books/foo.epub` 27`~/d/b/foo.epub`. Let's write a function `trimPath` to do this for us:
 28
 29```go
 30// Truncates the current working directory:
 31//   /home/icy/foo/bar -> ~/f/bar
 32func trimPath(cwd, home string) string {
 33	var path string
 34	if strings.HasPrefix(cwd, home) {
 35		path = "~" + strings.TrimPrefix(cwd, home)
 36	} else {
 37		// If path doesn't contain $HOME, return the
 38		// entire path as is.
 39		path = cwd
 40		return path
 41	}
 42	items := strings.Split(path, "/")
 43	truncItems := []string{}
 44	for i, item := range items {
 45		if i == (len(items) - 1) {
 46			truncItems = append(truncItems, item)
 47			break
 48		}
 49		truncItems = append(truncItems, item[:1])
 50	}
 51	return filepath.Join(truncItems...)
 52}
 53```
 54
 55`trimPath` takes two args: the current working directory `cwd`, and the
 56home directory `home`. We first check if `cwd` starts with `home`, i.e.
 57we're in a subdirectory of `home`; if yes, trim `home` from `cwd`, and
 58replace it with a tilde `~`. We now have `~/docs/books/foo.epub`.
 59
 60Also note that we return the path as-is if we're not in a subdir of
 61`home` -- i.e. paths under `/`, like `/usr`, etc. I like to see these
 62completely, just to be sure.
 63
 64We then split the path at `/`[^2], and truncate each item in the
 65resulting list -- except for the last -- down to the first character.
 66Join it all together and return the resulting string -- we have
 67`~/d/b/foo.epub`.
 68
 69[^2]: I don't care about Windows.
 70
 71Next up: color.
 72
 73```go
 74var (
 75	red   = color("\033[31m%s\033[0m")
 76	green = color("\033[32m%s\033[0m")
 77	cyan  = color("\033[36m%s\033[0m")
 78)
 79
 80func color(s string) func(...interface{}) string {
 81	return func(args ...interface{}) string {
 82		return fmt.Sprintf(s, fmt.Sprint(args...))
 83	}
 84}
 85```
 86... I'll just let you figure this one out.
 87
 88## git branch and clean/dirty info
 89
 90The defacto lib for git in Go is often
 91[go-git](https://github.com/go-git/go-git). I don't disagree that it is
 92a good library: clean APIs, good docs. It just has one huge issue, and
 93especially so in our case. It's `worktree.Status()` function -- used to
 94fetch the worktree status -- is [awfully
 95slow](https://github.com/go-git/go-git/issues/327). It's not noticeable
 96in small repositories, but even relatively large ones (~30MB) tend to
 97take about 20 seconds. That's super not ideal for a prompt.[^3]
 98
 99[^3]: https://git.icyphox.sh/dotfiles/commit/?id=e1f6aaaf6ffd35224b5d3f057c28fb2560e1c3b0
100
101The alternative? [libgit2/git2go](https://github.com/libgit2/git2go), of
102course! It's the Go bindings for libgit2 -- obviously, it requires CGo,
103but who cares.
104
105First things first, let's write `getGitDir` to find `.git` (indicating
106that it's a git repo), and return the repository path. We'll need this
107to use git2go's `OpenRepository()`.
108
109```go
110// Recursively traverse up until we find .git
111// and return the git repo path.
112func getGitDir() string {
113	cwd, _ := os.Getwd()
114	for {
115		dirs, _ := os.ReadDir(cwd)
116		for _, d := range dirs {
117			if ".git" == d.Name() {
118				return cwd
119			} else if cwd == "/" {
120				return ""
121			}
122		}
123		cwd = filepath.Dir(cwd)
124	}
125}
126```
127
128This traverses up parent directories until it finds `.git`, else,
129returns an empty string if we've reached `/`. For example: if you're in
130`~/code/foo/bar`, and the git repo root is at `~/code/foo/.git`, this
131function will find it.
132
133Alright, let's quickly write two more functions to return the git branch
134name, and the repository status -- i.e., dirty or clean.
135
136```go
137// Returns the current git branch or current ref sha.
138func gitBranch(repo *git.Repository) string {
139	ref, _ := repo.Head()
140	if ref.IsBranch() {
141		name, _ := ref.Branch().Name()
142		return name
143	} else {
144		return ref.Target().String()[:7]
145	}
146}
147```
148
149This takes a `*git.Repository`, where `git` is `git2go`. We first get
150the `git.Reference` and check whether it's a branch. If yes, return the
151name of the branch, else -- like in the case of a detached HEAD state --
152we just return a short hash.
153
154```go
155// Returns • if clean, else ×.
156func gitStatus(repo *git.Repository) string {
157	sl, _ := repo.StatusList(&git.StatusOptions{
158		Show:  git.StatusShowIndexAndWorkdir,
159		Flags: git.StatusOptIncludeUntracked,
160	})
161	n, _ := sl.EntryCount()
162	if n != 0 {
163		return red("×")
164	} else {
165		return green("•")
166	}
167}
168```
169
170We use the
171[`StatusList`](https://godocs.io/github.com/libgit2/git2go/v31#Repository.StatusList)
172function to produce a `StatusList` object. We then check the
173`EntryCount`, i.e., the number of modified/untracked/etc. files
174contained in `StatusList`.[^4] If this number is 0, our repo is clean;
175dirty otherwise. Colored symbols are returned accordingly.
176
177[^4]: This took me a lot of going back and forth, and reading
178      https://libgit2.org/libgit2/ex/HEAD/status.html to figure out.
179
180## putting it all together
181
182Home stretch. Let's write `makePrompt` to make our prompt.
183
184```go
185const (
186	promptSym = "▲"
187)
188
189func makePrompt() string {
190	cwd, _ := os.Getwd()
191	home := os.Getenv("HOME")
192	gitDir := getGitDir()
193	if len(gitDir) > 0 {
194		repo, _ := git.OpenRepository(getGitDir())
195		return fmt.Sprintf(
196			"\n%s (%s %s)\n%s",
197			cyan(trimPath(cwd, home)),
198			gitBranch(repo),
199			gitStatus(repo),
200			promptSym,
201		)
202	}
203	return fmt.Sprintf(
204		"\n%s\n%s",
205		cyan(trimPath(cwd, home)),
206		promptSym,
207	)
208}
209
210func main() {
211	fmt.Println(makePrompt())
212}
213```
214
215There isn't much going on here. Get the necessary pieces like the
216current working directory, home and the git repo path. We return the
217formatted prompt string according to whether we're in git or not.
218
219Setting the prompt is simple. Point `PS1` to the built binary:
220
221```bash
222PS1='$(~/dotfiles/prompt/prompt) '
223```
224
225And here's what it looks like, rendered:
226![go prompt](https://x.icyphox.sh/boh7u.png)
227
228## benchmarking
229
230Both "benchmarks" were run inside a sufficiently large git repository,
231deep inside many subdirs.
232
233To time the old bash prompt, I just copied all the bash functions,
234pasted it in my shell and ran:
235
236```shell
237~/C/d/a/n/y/b/d/t/yaml-1.1 (master •)
238time echo -e "\n$(prompt_pwd)$(git_branch)\n▲$(rootornot)"
239
240# output
241~/C/d/a/n/y/b/d/t/yaml-1.1 (master •)
242243
244real    0m0.125s
245user    0m0.046s
246sys     0m0.079s
247
248```
249
2500.125s. Not too bad. Let's see how long our Go prompt takes.
251
252```shell
253~/C/d/a/n/y/b/d/t/yaml-1.1 (master •)
254time ~/dotfiles/prompt/prompt
255
256# output
257~/C/d/a/n/y/b/d/t/yaml-1.1 (master •)
258259
260real    0m0.074s
261user    0m0.031s
262sys     0m0.041s
263
264```
265
2660.074s! That's pretty fast. I ran these tests a few more times, and the
267bash version was consistently slower -- averaging ~0.120s; the Go
268version averaging ~0.70s. That's a win.
269
270You can find the entire source [here](https://git.icyphox.sh/dotfiles/tree/prompt).