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