diff options
Diffstat (limited to 'gitstatus/README.md')
-rw-r--r-- | gitstatus/README.md | 529 |
1 files changed, 529 insertions, 0 deletions
diff --git a/gitstatus/README.md b/gitstatus/README.md new file mode 100644 index 00000000..291f89bf --- /dev/null +++ b/gitstatus/README.md @@ -0,0 +1,529 @@ +# gitstatus + +**gitstatus** is a 10x faster alternative to `git status` and `git describe`. Its primary use +case is to enable fast git prompt in interactive shells. + +Heavy lifting is done by **gitstatusd** -- a custom binary written in C++. It comes with Zsh and +Bash bindings for integration with shell. + +## Table of Contents + +1. [Using from Zsh](#using-from-zsh) +1. [Using from Bash](#using-from-bash) +2. [Using from other shells](#using-from-other-shells) +1. [How it works](#how-it-works) +1. [Benchmarks](#benchmarks) +1. [Why fast](#why-fast) +1. [Requirements](#requirements) +1. [Compiling](#compiling) +1. [License](#license) + +## Using from Zsh + +The easiest way to take advantage of gitstatus from Zsh is to use a theme that's already integrated +with it. For example, [Powerlevel10k](https://github.com/romkatv/powerlevel10k) is a flexible and +fast theme with first-class gitstatus integration. + +![Powerlevel10k Zsh Theme]( + https://raw.githubusercontent.com/romkatv/powerlevel10k-media/master/prompt-styles-high-contrast.png) + +For those who wish to use gitstatus without a theme, there is +[gitstatus.prompt.zsh](gitstatus.prompt.zsh). Install it as follows: + +```zsh +git clone --depth=1 https://github.com/romkatv/gitstatus.git ~/gitstatus +echo 'source ~/gitstatus/gitstatus.prompt.zsh' >>! ~/.zshrc +``` + +Users in mainland China can use the official mirror on gitee.com for faster download.<br> +中国大陆用户可以使用 gitee.com 上的官方镜像加速下载. + +```zsh +git clone --depth=1 https://gitee.com/romkatv/gitstatus.git ~/gitstatus +echo 'source ~/gitstatus/gitstatus.prompt.zsh' >>! ~/.zshrc +``` + +Alternatively, on macOS you can install with Homebrew: + +```zsh +brew install romkatv/gitstatus/gitstatus +echo 'source /usr/local/opt/gitstatus/gitstatus.prompt.zsh' >>! ~/.zshrc +``` + +(If you choose this option, replace `~/gitstatus` with `/usr/local/opt/gitstatus` in all code +snippets below.) + +_Make sure to disable your current theme if you have one._ + +This will give you a basic yet functional prompt with git status in it. It's +[over 10x faster](#benchmarks) than any alternative that can give you comparable prompt. In order +to customize it, set `PROMPT` and/or `RPROMPT` at the end of `~/.zshrc` after sourcing +`gitstatus.prompt.zsh`. Insert `${GITSTATUS_PROMPT}` where you want git status to go. For example: + +```zsh +source ~/gitstatus/gitstatus.prompt.zsh + +PROMPT='%~%# ' # left prompt: directory followed by %/# (normal/root) +RPROMPT='$GITSTATUS_PROMPT' # right prompt: git status +``` + +The expansion of `${GITSTATUS_PROMPT}` can contain the following bits: + +| segment | meaning | +|-------------|-------------------------------------------------------| +| `master` | current branch | +| `#v1` | HEAD is tagged with `v1`; not shown when on a branch | +| `@5fc6fca4` | current commit; not shown when on a branch or tag | +| `⇣1` | local branch is behind the remote by 1 commit | +| `⇡2` | local branch is ahead of the remote by 2 commits | +| `⇠3` | local branch is behind the push remote by 3 commits | +| `⇢4` | local branch is ahead of the push remote by 4 commits | +| `*5` | there are 5 stashes | +| `merge` | merge is in progress (could be some other action) | +| `~6` | there are 6 merge conflicts | +| `+7` | there are 7 staged changes | +| `!8` | there are 8 unstaged changes | +| `?9` | there are 9 untracked files | + +`$GITSTATUS_PROMPT_LEN` tells you how long `$GITSTATUS_PROMPT` is when printed to the console. +[gitstatus.prompt.zsh](gitstatus.prompt.zsh) has an example of using it to truncate the current +directory. + +If you'd like to change the format of git status, or want to have greater control over the +process of assembling `PROMPT`, you can copy and modify parts of +[gitstatus.prompt.zsh](gitstatus.prompt.zsh) instead of sourcing the script. Your `~/.zshrc` +might look something like this: + +```zsh +source ~/gitstatus/gitstatus.plugin.zsh + +function my_set_prompt() { + PROMPT='%~%# ' + RPROMPT='' + + if gitstatus_query MY && [[ $VCS_STATUS_RESULT == ok-sync ]]; then + RPROMPT=${${VCS_STATUS_LOCAL_BRANCH:-@${VCS_STATUS_COMMIT}}//\%/%%} # escape % + (( $VCS_STATUS_NUM_STAGED )) && RPROMPT+='+' + (( $VCS_STATUS_NUM_UNSTAGED )) && RPROMPT+='!' + (( $VCS_STATUS_NUM_UNTRACKED )) && RPROMPT+='?' + fi + + setopt no_prompt_{bang,subst} prompt_percent # enable/disable correct prompt expansions +} + +gitstatus_stop 'MY' && gitstatus_start -s -1 -u -1 -c -1 -d -1 'MY' +autoload -Uz add-zsh-hook +add-zsh-hook precmd my_set_prompt +``` + +This snippet is sourcing `gitstatus.plugin.zsh` rather than `gitstatus.prompt.zsh`. The former +defines low-level bindings that communicate with gitstatusd over pipes. The latter is a simple +script that uses these bindings to assemble git prompt. + +Unlike [Powerlevel10k](https://github.com/romkatv/powerlevel10k), code based on +[gitstatus.prompt.zsh](gitstatus.prompt.zsh) is communicating with gitstatusd synchronously. This +can make your prompt slow when working in a large git repository or on a slow machine. To avoid +this problem, call `gitstatus_query` asynchronously as documented in +[gitstatus.plugin.zsh](gitstatus.plugin.zsh). This can be quite challenging. + +## Using from Bash + +The easiest way to take advantage of gitstatus from Bash is via +[gitstatus.prompt.sh](gitstatus.prompt.sh). Install it as follows: + +```bash +git clone --depth=1 https://github.com/romkatv/gitstatus.git ~/gitstatus +echo 'source ~/gitstatus/gitstatus.prompt.sh' >> ~/.bashrc +``` + +Users in mainland China can use the official mirror on gitee.com for faster download.<br> +中国大陆用户可以使用 gitee.com 上的官方镜像加速下载. + +```bash +git clone --depth=1 https://gitee.com/romkatv/gitstatus.git ~/gitstatus +echo 'source ~/gitstatus/gitstatus.prompt.sh' >> ~/.bashrc +``` + +Alternatively, on macOS you can install with Homebrew: + +```zsh +brew install romkatv/gitstatus/gitstatus +echo 'source /usr/local/opt/gitstatus/gitstatus.prompt.sh' >> ~/.bashrc +``` + +(If you choose this option, replace `~/gitstatus` with `/usr/local/opt/gitstatus` in all code +snippets below.) + +This will give you a basic yet functional prompt with git status in it. It's +[over 10x faster](#benchmarks) than any alternative that can give you comparable prompt. + +![Bash Prompt with GitStatus]( + https://raw.githubusercontent.com/romkatv/gitstatus/1ac366952366d89980b3f3484f270b4fa5ae4293/bash-prompt.png) + +In order to customize your prompt, set `PS1` at the end of `~/.bashrc` after sourcing +`gitstatus.prompt.sh`. Insert `${GITSTATUS_PROMPT}` where you want git status to go. For example: + +```bash +source ~/gitstatus/gitstatus.prompt.sh + +PS1='\w ${GITSTATUS_PROMPT}\n\$ ' # directory followed by git status and $/# (normal/root) +``` + +The expansion of `${GITSTATUS_PROMPT}` can contain the following bits: + +| segment | meaning | +|-------------|-------------------------------------------------------| +| `master` | current branch | +| `#v1` | HEAD is tagged with `v1`; not shown when on a branch | +| `@5fc6fca4` | current commit; not shown when on a branch or tag | +| `⇣1` | local branch is behind the remote by 1 commit | +| `⇡2` | local branch is ahead of the remote by 2 commits | +| `⇠3` | local branch is behind the push remote by 3 commits | +| `⇢4` | local branch is ahead of the push remote by 4 commits | +| `*5` | there are 5 stashes | +| `merge` | merge is in progress (could be some other action) | +| `~6` | there are 6 merge conflicts | +| `+7` | there are 7 staged changes | +| `!8` | there are 8 unstaged changes | +| `?9` | there are 9 untracked files | + +If you'd like to change the format of git status, or want to have greater control over the +process of assembling `PS1`, you can copy and modify parts of +[gitstatus.prompt.sh](gitstatus.prompt.sh) instead of sourcing the script. Your `~/.bashrc` might +look something like this: + +```bash +source ~/gitstatus/gitstatus.plugin.sh + +function my_set_prompt() { + PS1='\w' + + if gitstatus_query && [[ "$VCS_STATUS_RESULT" == ok-sync ]]; then + if [[ -n "$VCS_STATUS_LOCAL_BRANCH" ]]; then + PS1+=" ${VCS_STATUS_LOCAL_BRANCH//\\/\\\\}" # escape backslash + else + PS1+=" @${VCS_STATUS_COMMIT//\\/\\\\}" # escape backslash + fi + [[ "$VCS_STATUS_HAS_STAGED" == 1 ]] && PS1+='+' + [[ "$VCS_STATUS_HAS_UNSTAGED" == 1 ]] && PS1+='!' + [[ "$VCS_STATUS_HAS_UNTRACKED" == 1 ]] && PS1+='?' + fi + + PS1+='\n\$ ' + + shopt -u promptvars # disable expansion of '$(...)' and the like +} + +gitstatus_stop && gitstatus_start +PROMPT_COMMAND=my_set_prompt +``` + +This snippet is sourcing `gitstatus.plugin.sh` rather than `gitstatus.prompt.sh`. The former +defines low-level bindings that communicate with gitstatusd over pipes. The latter is a simple +script that uses these bindings to assemble git prompt. + +Note: Bash bindings, unlike Zsh bindings, don't support asynchronous calls. + +## Using from other shells + +If there are no gitstatusd bindings for your shell, you'll need to get your hands dirty. +Use the existing bindings for inspiration; run `gitstatusd --help` or read the same thing in +[options.cc](src/options.cc). + +## How it works + +gitstatusd reads requests from stdin and prints responses to stdout. Requests contain an ID and +a directory. Responses contain the same ID and machine-readable git status for the directory. +gitstatusd keeps some state in memory for the directories it has seen in order to serve future +requests faster. + +[Zsh bindings](gitstatus.plugin.zsh) and [Bash bindings](gitstatus.plugin.sh) start gitstatusd in +the background and communicate with it via pipes. Themes such as +[Powerlevel10k](https://github.com/romkatv/powerlevel10k) use these bindings to put git status in +`PROMPT`. + +Note that gitstatus cannot be used as a drop-in replacement for `git status` command as it doesn't +produce output in the same format. It does perform the same computation though. + +## Benchmarks + +The following benchmark results were obtained on Intel i9-7900X running Ubuntu 18.04 in +a clean [chromium](https://github.com/chromium/chromium) repository synced to `9394e49a`. The +repository was checked out to an ext4 filesystem on M.2 SSD. + +Three functionally equivalent tools for computing git status were benchmarked: + +* `gitstatusd` +* `git` with untracked cache enabled +* `lg2` -- a demo/example executable from [libgit2](https://github.com/romkatv/libgit2) that + implements a subset of `git` functionality on top of libgit2 API; for the purposes of this + benchmark the subset is sufficient to generate the same data as the other tools + +Every tool was benchmark in cold and hot conditions. For `git` the first run in a repository was +considered cold, with the following runs considered hot. `lg2` was patched to compute results twice +in a single invocation without freeing the repository in between; the second run was considered hot. +The same patching was not done for `git` because `git` cannot be easily modified to refresh inmemory +index state between invocations; in fact, this limitation is one of the primary reasons developers +use libgit2. `gitstatusd` was benchmarked similarly to `lg2` with two result computations in the +same invocation. + +Two commands were benchmarked: `status` and `describe`. + +### Status + +In this benchmark all tools were computing the equivalent of `git status`. Lower numbers are better. + +| Tool | Cold | Hot | +|---------------|-----------:|------------:| +| **gitstatus** | **291 ms** | **30.9 ms** | +| git | 876 ms | 295 ms | +| lg2 | 1730 ms | 1310 ms | + +gitstatusd is substantially faster than the alternatives, especially on hot runs. Note that hot runs +are of primary importance to the main use case of gitstatus in interactive shells. + +The performance of `git status` fluctuated wildly in this benchmarks for reasons unknown to the +author. Moreover, performance is sticky -- once `git status` settles around a number, it stays +there for a long time. Numbers as diverse as 295, 352, 663 and 730 had been observed on hot runs on +the same repository. The number in the table is the lowest (fastest or best) that `git status` had +shown. + +### Describe + +In this benchmark all tools were computing the equivalent of `git describe --tags --exact-match` +to find tags that resolve to the same commit as `HEAD`. Lower numbers are better. + +| Tool | Cold | Hot | +|---------------|------------:|--------------:| +| **gitstatus** | **4.04 ms** | **0.0345 ms** | +| git | 18.0 ms | 14.5 ms | +| lg2 | 185 ms | 45.2 ms | + +gitstatusd is once again faster than the alternatives, more so on hot runs. + +## Why fast + +Since gitstatusd doesn't have to print all staged/unstaged/untracked files but only report +whether there are any, it can terminate repository scan early. It can also remember which files +were dirty on the previous run and check them first on the next run to avoid the scan entirely if +the files are still dirty. However, the benchmarks above were performed in a clean repository where +these shortcuts do not trigger. All benchmarked tools had to do the same work -- check the status +of every file in the index to see if it has changed, check every directory for newly created files, +etc. And yet, gitstatusd came ahead by a large margin. This section describes what it does that +makes it so fast. + +Most of the following comparisons are done against libgit2 rather than git because of the author's +familiarity with the former but not the with latter. libgit2 has clean, well-documented APIs and an +elegant implementation, which makes it so much easier to work with and to analyze performance +bottlenecks. + +### Summary for the impatient + +Under the benchmark conditions described above, the equivalent of libgit2's +`git_diff_index_to_workdir` (the most expensive part of `status` command) is 46.3 times faster in +gitstatusd. The speedup comes from the following sources. + +* gitstatusd uses more efficient data structures and algorithms and employs performance-conscious +coding style throughout the codebase. This reduces CPU time in userspace by 32x compared to libgit2. +* gitstatusd uses less expensive system calls and makes fewer of them. This reduces CPU time spent +in kernel by 1.9x. +* gitstatusd can utilize multiple cores to scan index and workdir in parallel with almost perfect +scaling. This reduces total run time by 12.4x while having virtually no effect on total CPU time. + +### Problem statement + +The most resource-intensive part of the `status` command is finding the difference between _index_ +and _workdir_ (`git_diff_index_to_workdir` in libgit2). Index is a list of all files in the git +repository with their last modification times. This is an obvious simplification but it suffices for +this exposition. On disk, index is stored sorted by file path. Here's an example of git index: + +| File | Last modification time | +|-------------|-----------------------:| +| Makefile | 2019-04-01T14:12:32Z | +| src/hello.c | 2019-04-01T14:12:00Z | +| src/hello.h | 2019-04-01T14:12:32Z | + +This list needs to be compared to the list of files in the working directory. If any of the files +listed in the index are missing from the workdir or have different last modification time, they are +"unstaged" in gitstatusd parlance. If you run `git status`, they'll be shown as "changes not staged +for commit". Thus, any implementation of `status` command has to call `stat()` or one of its +variants on every file in the index. + +In addition, all files in the working directory for which there is no entry in the index at all are +"untracked". `git status` will show them as "untracked files". Finding untracked files requires some +form of work directory traversal. + +### Single-threaded scan + +Let's see how `git_diff_index_to_workdir` from libgit2 accomplishes these tasks. Here's its CPU +profile from 200 hot runs over chromium repository. + +![libgit2 CPU profile (hot)]( + https://raw.githubusercontent.com/romkatv/gitstatus/1ac366952366d89980b3f3484f270b4fa5ae4293/cpu-profile-libgit2.png) + +(The CPU profile was created with [gperftools](https://github.com/gperftools/gperftools) and +rendered with [pprof](https://github.com/google/pprof)). + +We can see `__GI__lxstat` taking a lot of time. This is the `stat()` call for every file in the +index. We can also identify `__opendir`, `__readdir` and `__GI___close_nocancel` -- glibc wrappers +for reading the contents of a directory. This is for finding untracked files. Out of the total 232 +seconds, 111 seconds -- or 47.7% -- was spent on these calls. The rest is computation -- comparing +strings, sorting arrays, etc. + +Now let's take a look at the CPU profile of gitstatusd on the same task. + +![gitstatusd CPU profile (hot)]( + https://raw.githubusercontent.com/romkatv/gitstatus/1ac366952366d89980b3f3484f270b4fa5ae4293/cpu-profile-gitstatusd-hot.png) + +The first impression is that this profile looks pruned. This isn't an artifact. The profile was +generated with the same tools and the same flags as the profile of libgit2. + +Since both profiles were generated from the same workload, absolute numbers can be compared. We can +see that gitstatusd took 62 seconds in total compared to libgit2's 232 seconds. System calls at the +core of the algorithm are cleary visible. `__GI___fxstatat` is a flavor of `stat()`, and the other +three calls -- `__libc_openat64`, `__libc_close` and `__GI___fxstat` are responsible for opening +directories and finding untracked files. Notice that there is almost nothing else in the profile +apart from these calls. The rest of the code accounts for 3.77 seconds of CPU time -- 32 times less +than in libgit2. + +So, one reason gitstatusd is fast is that it has efficient diffing code -- very little time is spent +outside of kernel. However, if we look closely, we can notice that system calls in gitstatusd are +_also_ faster than in libgit2. For example, libgit2 spent 72.07 seconds in `__GI__lxstat` while +gitstatusd spent only 48.82 seconds in `__GI___fxstatat`. There are two reasons for this difference. +First, libgit2 makes more `stat()` calls than is strictly required. It's not necessary to stat +directories because index only has files. There are 25k directories in chromium repository (and 300k +files) -- that's 25k `stat()` calls that could be avoided. The second reason is that libgit2 and +gitstatusd use different flavors of `stat()`. libgit2 uses `lstat()`, which takes a path to the file +as input. Its performance is linear in the number of subdirectories in the path because it needs to +perform a lookup for every one of them and to check permissions. gitstatusd uses `fstatat()`, which +takes a file descriptor to the parent directory and a name of the file. Just a single lookup, less +CPU time. + +Similarly to `lstat()` vs `fstatat()`, it's faster to open files and directories with `openat()` +from the parent directory file descriptor than with regular `open()` that accepts full file path. +gitstatusd takes advantage of `openat()` to open directories as fast as possible. It opens about 90% +of the directories (this depends on the actual directory structure of the repository) from the +immediate parent -- the most efficient way -- and the remaining 10% it opens from the repository's +root directory. The reason it's done this way is to keep the maximum number of simultaneously open +file descriptors bounded. libgit2 can have O(repository depth) simultaneously open file descriptors, +which may be OK for a single-threaded application but can balloon to a large number when scans are +done by many threads simultaneously, like in gitstatusd. + +There is no equivalent to `__opendir` or `__readdir` in the gitstatusd profile because it uses the +equivalent of [untracked cache](https://git-scm.com/docs/git-update-index#_untracked_cache) from +git. On the first scan of the workdir gitstatusd lists all files just like libgit2. But, unlike +libgit2, it remembers the last modification time of every directory along with the list of +untracked files under it. On the next scan, gitstatusd can skip listing files in directories whose +last modification time hasn't changed. + +To summarize, here's what gitstatusd was doing when the CPU profile was captured: + +1. `__libc_openat64`: Open every directory for which there are files in the index. +2. `__GI___fxstat`: Check last modification time of the directory. Since it's the same as on the + last scan, this directory has the same list of untracked files as before, which is empty (the + repository is clean). +3. `__GI___fxstatat`: Check last modification time for every file in the index that belongs to this + directory. +4. `__libc_close`: Close the file descriptor to the directory. + +Here's how the very first scan of a repository looks like in gitstatusd: + +![gitstatusd CPU profile (cold)]( + https://raw.githubusercontent.com/romkatv/gitstatus/1ac366952366d89980b3f3484f270b4fa5ae4293/cpu-profile-gitstatusd-cold.png) + +(Some glibc functions are mislabel on this profile. `explicit_bzero` and `__nss_passwd_lookup` are +in reality `strcmp` and `memcmp`.) + +This is a superset of the previous -- hot -- profile, with an extra `syscall` and string sorting for +directory listing. gitstatusd uses `getdents64` Linux system call directly, bypassing the glibc +wrapper that libgit2 uses. This is 23% faster. The details of this optimization can be found in a +[separate document](docs/listdir.md). + +### Multithreading + +The diffing algorithm in gitstatusd was designed from the ground up with the intention of using it +concurrently from multiple threads. With a fast SSD, `status` is CPU bound, so taking advantage of +all available CPU cores is an obvious way to yield results faster. + +gitstatusd exhibits almost perfect scaling from multithreading. Engaging all cores allows it to +produce results 12.4 times faster than in single-threaded execution. This is on Intel i9-7900X with +10 cores (20 with hyperthreading) with single-core frequency of 4.3GHz and all-core frequency of +4.0GHz. + +Note: `git status` also uses all available cores in some parts of its algorithm while `lg2` does +everything in a single thread. + +### Postprocessing + +Once the difference between the index and the workdir is found, we have a list of _candidates_ -- +files that may be unstaged or untracked. To make the final judgement, these files need to be checked +against `.gitignore` rules and a few other things. + +gitstatusd uses [patched libgit2](https://github.com/romkatv/libgit2) for this step. This fork +adds several optimizations that make libgit2 faster. The patched libgit2 performs more than twice +as fast in the benchmark as the original even without changes in the user code (that is, in the +code that uses the libgit2 APIs). The fork also adds several API extensions, most notable of which +is the support for multi-threaded scans. If `lg2 status` is modified to take advantage of these +extensions, it outperforms the original libgit2 by a factor of 18. Lastly, the fork fixes a score of +bugs, most of which become apparent only when using libgit2 from multiple threads. + +_WARNING: Changes to libgit2 are extensive but the testing they underwent isn't. It is +**not recommended** to use the patched libgit2 in production._ + +## Requirements + +* To compile: binutils, cmake, gcc, g++, git and GNU make. +* To run: Linux, macOS, FreeBSD, Android, WSL, Cygwin or MSYS2. + +## Compiling + +There are prebuilt `gitstatusd` binaries in [releases]( + https://github.com/romkatv/gitstatus/releases). When using the official shell bindings +provided by gitstatus, the right binary for your architecture gets downloaded automatically. + +If prebuilt binaries don't work for you, you'll need to get your hands dirty. + +### Compiling for personal use + +```zsh +git clone --depth=1 https://github.com/romkatv/gitstatus.git +cd gitstatus +./build -w -s -d docker +``` + +Users in mainland China can use the official mirror on gitee.com for faster download.<br> +中国大陆用户可以使用 gitee.com 上的官方镜像加速下载. + +```zsh +git clone --depth=1 https://gitee.com/romkatv/gitstatus.git +cd gitstatus +./build -w -s -d docker +``` + +- If it says that `-d docker` is not supported on your OS, remove this flag. +- If it says that `-s` is not supported on your OS, remove this flag. +- If it tell you to install docker but you cannot or don't want to, remove `-d docker`. +- If it says that some command is missing, install it. + +If everything goes well, the newly built binary will appear in `./usrbin`. It'll be picked up +by shell bindings automatically. + +When you update shell bindings, they may refuse to work with the binary you've built earlier. In +this case you'll need to rebuild. + +If you are using gitstatus through [Powerlevel10k](https://github.com/romkatv/powerlevel10k), the +instructions are the same except that you don't need to clone gitstatus. Instead, change your +current directory to `/path/to/powerlevel10k/gitstatus` (`/path/to/powerlevel10k` is the directory +where you've installed Powerlevel10k) and run `./build -w -s -d docker` from there as described +above. + +### Compiling for distribution + +It's currently neither easy nor recommended to package and distribute gitstatus. There are no +instructions you can follow that would allow you to easily update your package when new versions of +gitstatus are released. This may change in the future but not soon. + +## License + +GNU General Public License v3.0. See [LICENSE](LICENSE). Contributions are covered by the same +license. |