diff --git a/pure.zsh b/pure.zsh index 6986af2..beb71eb 100644 --- a/pure.zsh +++ b/pure.zsh @@ -314,7 +314,8 @@ prompt_pure_async_vcs_info() { print -r - ${(@kvq)info} } -# Fastest possible way to check if a Git repo is dirty. +# Check if a Git repo is dirty and report detailed status. +# Outputs a markers string: * (unstaged), + (staged), ? (untracked). prompt_pure_async_git_dirty() { setopt localoptions noshwordsplit local untracked_dirty=$1 @@ -326,13 +327,32 @@ prompt_pure_async_git_dirty() { # Prevent e.g. `git status` from refreshing the index as a side effect. export GIT_OPTIONAL_LOCKS=0 + local u_flag if [[ $untracked_dirty = 0 ]]; then - command git diff --no-ext-diff --quiet --exit-code + u_flag='-uno' else - test -z "$(command git status --porcelain -u${untracked_git_mode})" + u_flag="-u${untracked_git_mode}" fi - return $? + local output + output=$(command git status --porcelain $u_flag) + [[ -z $output ]] && return 0 + + local has_unstaged=0 has_staged=0 has_untracked=0 line + for line in "${(f)output}"; do + (( ! has_unstaged )) && [[ ${line[2]} == [MTDUA] ]] && has_unstaged=1 + (( ! has_staged )) && [[ ${line[1]} == [MTADRCU] ]] && has_staged=1 + (( ! has_untracked )) && [[ $line == '??'* ]] && has_untracked=1 + (( has_unstaged + has_staged + has_untracked == 3 )) && break + done + + local markers="" + (( has_unstaged )) && markers+="*" + (( has_staged )) && markers+="+" + (( has_untracked )) && markers+="?" + + print -r - "$markers" + return 1 } prompt_pure_async_git_fetch() { @@ -674,7 +694,7 @@ prompt_pure_async_callback() { if (( code == 0 )); then unset prompt_pure_git_dirty else - typeset -g prompt_pure_git_dirty="*" + typeset -g prompt_pure_git_dirty="${output:-*}" fi [[ $prev_dirty != $prompt_pure_git_dirty ]] && do_render=1 @@ -921,7 +941,7 @@ prompt_pure_preview() { fi # Sample preprompt with all components visible. - print -P "%F{$c[suspended_jobs]}${PURE_SUSPENDED_JOBS_SYMBOL-✦}%f %F{$c[user]}zaphod%f${host_sample} ${path_sample} %F{$c[git:branch]}main%f%F{$c[git:dirty]}*%f %F{$c[git:action]}rebase-i%f %F{$c[git:arrow]}${PURE_GIT_DOWN_ARROW:-⇣}${PURE_GIT_UP_ARROW:-⇡}%f %F{$c[git:stash]}${PURE_GIT_STASH_SYMBOL-≡}%f %F{$c[node_version]}${node_symbol}22%f %F{$c[execution_time]}42s%f" + print -P "%F{$c[suspended_jobs]}${PURE_SUSPENDED_JOBS_SYMBOL-✦}%f %F{$c[user]}zaphod%f${host_sample} ${path_sample} %F{$c[git:branch]}main%f%F{$c[git:dirty]}*+%f %F{$c[git:action]}rebase-i%f %F{$c[git:arrow]}${PURE_GIT_DOWN_ARROW:-⇣}${PURE_GIT_UP_ARROW:-⇡}%f %F{$c[git:stash]}${PURE_GIT_STASH_SYMBOL-≡}%f %F{$c[node_version]}${node_symbol}22%f %F{$c[execution_time]}42s%f" print -P "%F{$c[virtualenv]}venv%f %F{$c[prompt:success]}${PURE_PROMPT_SYMBOL:-❯}%f" print print -P "%F{$c[prompt:error]}${PURE_PROMPT_SYMBOL:-❯}%f prompt after error" @@ -1011,7 +1031,7 @@ prompt_pure_setup() { # psvar[12] = suspended jobs symbol (e.g. ✦) # psvar[13] = username flag, renders user/host (e.g. user@host) # psvar[14] = git branch - # psvar[15] = git dirty marker, nested inside [14] conditional + # psvar[15] = git dirty markers (* unstaged, + staged, ? untracked) # psvar[16] = git action (e.g. rebase, merge) # psvar[17] = git arrows (e.g. ⇣⇡) # psvar[18] = git stash symbol (e.g. ≡) @@ -1020,7 +1040,7 @@ prompt_pure_setup() { # psvar[21] = Node.js version (e.g. ⬢22) # # Example output: - # ✦ user@host ~/Code/pure main* rebase ⇣⇡ ≡ ⬢22 3s + # ✦ user@host ~/Code/pure main*+ rebase ⇣⇡ ≡ ⬢22 3s # myenv ❯ # # Preprompt line: each %(NV..) section only renders when its psvar is non-empty. diff --git a/readme.md b/readme.md index 3831fd0..fbac6cb 100644 --- a/readme.md +++ b/readme.md @@ -12,7 +12,7 @@ Most prompts are cluttered, ugly and slow. We wanted something visually pleasing - Comes with the perfect prompt character. Author went through the whole Unicode range to find it. -- Shows `git` branch and whether it's dirty (with a `*`). +- Shows `git` branch and whether it's dirty (`*` unstaged, `+` staged, `?` untracked). - Indicates when you have unpushed/unpulled `git` commits with up/down arrows. *(Check is done asynchronously!)* - Prompt character turns red if the last command didn't exit with `0`. - Command execution time will be displayed if it exceeds the set threshold. @@ -147,7 +147,7 @@ Colors can be changed by using [`zstyle`](http://zsh.sourceforge.net/Doc/Release - `git:branch` (242) - The name of the current branch when in a Git repository. - `git:branch:cached` (red) - The name of the current branch when the data isn't fresh. - `git:action` (yellow) - The current action in progress (cherry-pick, rebase, etc.) when in a Git repository. -- `git:dirty` (218) - The asterisk showing the branch is dirty. +- `git:dirty` (218) - The dirty indicators showing the branch state (`*` unstaged, `+` staged, `?` untracked). - `host` (242) - The hostname when on a remote machine. - `node_version` (green) - The current major Node.js version in directories with `package.json`. - `path` (blue) - The current path, for example, `PWD`. @@ -166,14 +166,14 @@ The following diagram shows where each color is applied on the prompt: │ ┌─────────────────────────────────────────────── host │ │ ┌─────────────────────────────────── path │ │ │ ┌──────────────────────── git:branch -│ │ │ │ ┌────────────────────── git:dirty -│ │ │ │ │ ┌──────────────────── git:action -│ │ │ │ │ │ ┌─────────── git:arrow -│ │ │ │ │ │ │ ┌───────── git:stash -│ │ │ │ │ │ │ │ ┌─────── node_version -│ │ │ │ │ │ │ │ │ ┌── execution_time -│ │ │ │ │ │ │ │ │ │ -zaphod@heartofgold ~/dev/pure main* rebase-i ⇡ ≡ ⬢22 42s +│ │ │ │ ┌─────────────────────── git:dirty +│ │ │ │ │ ┌──────────────────── git:action +│ │ │ │ │ │ ┌─────────── git:arrow +│ │ │ │ │ │ │ ┌───────── git:stash +│ │ │ │ │ │ │ │ ┌─────── node_version +│ │ │ │ │ │ │ │ │ ┌── execution_time +│ │ │ │ │ │ │ │ │ │ +zaphod@heartofgold ~/dev/pure main*+ rebase-i ⇡ ≡ ⬢22 42s venv ❯ │ │ │ └──────────────────────────────────────────────── prompt diff --git a/tests/git-dirty.zsh b/tests/git-dirty.zsh new file mode 100644 index 0000000..f81161b --- /dev/null +++ b/tests/git-dirty.zsh @@ -0,0 +1,138 @@ +#!/usr/bin/env zsh + +setopt clobber +set -euo pipefail + +zmodload -F zsh/files b:zf_rm b:zf_mkdir + +source "${0:A:h}/test-helper.zsh" + +tmpdir="$PWD/.ai-temporary/git-dirty-test.$$" +zf_mkdir -p "$tmpdir" + +cleanup() { + zf_rm -rf -- "$tmpdir" +} +trap cleanup EXIT + +setup_repo() { + cd "$tmpdir" + command git init -q + command git config user.email "test@test.com" + command git config user.name "Test" + echo "initial" > file.txt + command git add file.txt + command git commit -q -m "initial" +} + +check_dirty() { + local dirty_output dirty_code + dirty_output=$(prompt_pure_async_git_dirty "$@") && dirty_code=0 || dirty_code=$? + typeset -g check_output=$dirty_output check_code=$dirty_code +} + +setup_repo + +# Clean repo. +check_dirty 1 +assert_equal 0 $check_code "clean repo should return 0" +assert_empty "$check_output" "clean repo should have no output" + +# Unstaged changes only. +echo "modified" > file.txt +check_dirty 1 +assert_equal 1 $check_code "unstaged should return 1" +assert_equal "*" "$check_output" "unstaged only should show *" + +# Staged changes only. +command git add file.txt +check_dirty 1 +assert_equal 1 $check_code "staged should return 1" +assert_equal "+" "$check_output" "staged only should show +" + +# Staged + intent-to-add. +echo "intent" > intent.txt +command git add -N intent.txt +check_dirty 1 +assert_equal 1 $check_code "staged+intent-to-add should return 1" +assert_equal "*+" "$check_output" "staged+intent-to-add should show *+" +command git reset -q HEAD -- intent.txt +zf_rm -f intent.txt + +# Staged + unstaged (same file). +echo "more changes" > file.txt +check_dirty 1 +assert_equal 1 $check_code "staged+unstaged should return 1" +assert_equal "*+" "$check_output" "staged+unstaged should show *+" + +# Untracked only (commit current changes first). +command git add file.txt +command git commit -q -m "second" +echo "new" > newfile.txt +check_dirty 1 +assert_equal 1 $check_code "untracked should return 1" +assert_equal "?" "$check_output" "untracked only should show ?" + +# All three: unstaged + staged + untracked. +echo "modified again" > file.txt +command git add file.txt +echo "even more" > file.txt +check_dirty 1 +assert_equal 1 $check_code "all three should return 1" +assert_equal "*+?" "$check_output" "all three should show *+?" + +# PURE_GIT_UNTRACKED_DIRTY=0 with only untracked files. +command git add file.txt +command git commit -q -m "third" +zf_rm -f newfile.txt +echo "untracked" > anotherfile.txt +check_dirty 0 +assert_equal 0 $check_code "untracked with PURE_GIT_UNTRACKED_DIRTY=0 should be clean" +assert_empty "$check_output" "untracked with PURE_GIT_UNTRACKED_DIRTY=0 should have no output" + +# PURE_GIT_UNTRACKED_DIRTY=0 with unstaged changes only. +echo "modified" > file.txt +check_dirty 0 +assert_equal 1 $check_code "unstaged with PURE_GIT_UNTRACKED_DIRTY=0 should return 1" +assert_equal "*" "$check_output" "unstaged with PURE_GIT_UNTRACKED_DIRTY=0 should show *" + +# PURE_GIT_UNTRACKED_DIRTY=0 with staged changes. +command git checkout -- file.txt +echo "staged" > staged.txt +command git add staged.txt +check_dirty 0 +assert_equal 1 $check_code "staged with PURE_GIT_UNTRACKED_DIRTY=0 should return 1" +assert_equal "+" "$check_output" "staged with PURE_GIT_UNTRACKED_DIRTY=0 should show +" + +# Deleted file (unstaged). +command git commit -q --allow-empty -m "prep" +command git checkout -- file.txt 2>/dev/null || true +zf_rm -f anotherfile.txt staged.txt +command git add -A +command git commit -q -m "clean slate" +echo "to delete" > deleteme.txt +command git add deleteme.txt +command git commit -q -m "add deleteme" +zf_rm -f deleteme.txt +check_dirty 1 +assert_equal 1 $check_code "deleted unstaged should return 1" +assert_equal "*" "$check_output" "deleted unstaged should show *" + +# Deleted file (staged). +command git rm -q deleteme.txt +check_dirty 1 +assert_equal 1 $check_code "deleted staged should return 1" +assert_equal "+" "$check_output" "deleted staged should show +" + +# Renamed file (staged). +command git reset -q HEAD -- deleteme.txt +command git checkout -- deleteme.txt 2>/dev/null || true +echo "to rename" > renameme.txt +command git add renameme.txt +command git commit -q -m "add renameme" +command git mv renameme.txt renamed.txt +check_dirty 1 +assert_equal 1 $check_code "renamed staged should return 1" +assert_equal "+" "$check_output" "renamed staged should show +" + +print "git-dirty tests passed"