summaryrefslogtreecommitdiffstats
path: root/git-sonar
blob: 4da43f8e210080c2f82305a31cdb35337c6eba2c (plain) (blame)
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
#!/bin/sh

GIT_SONAR_VERSION="v0.9.0-dev"

usage() {
    echo "git-sonar [--fetch] [--no-remote-commits] [--no-local-commits]"
    echo "          [--no-status] [--no-stash]"
    echo ""
    echo "git-sonar - a heads up display for git"
    echo "  $GIT_SONAR_VERSION"
    echo ""
    echo "  --fetch"
    echo "      Periodically fetch from remote repositories asynchronously in"
    echo "      the background."
    echo ""
    echo "  --no-remote-commits"
    echo "      Disable showing commit counts between current upstream branch"
    echo "      and the project default branch."
    echo ""
    echo "  --no-local-commits"
    echo "      Disable showing commit counts between the current local and"
    echo "      upstream branches."
    echo ""
    echo "  --no-status"
    echo "      Disable showing summary of uncommitted changes.  Often faster,"
    echo "      but omits a lot of information."
    echo ""
    echo "  --no-stash"
    echo "      Disable showing git stash count."
    echo ""
    echo "git-sonar is intended to be invoked by your shell prompt and may be"
    echo "configured through the use of various environment variables.  See"
    echo "the documentation for more details."
    exit 0
}

opt_fetch=""
opt_remote="true"
opt_local="true"
opt_status="true"
opt_stash="true"

while true; do
    case "$1" in
        --fetch) opt_fetch="true" ;;
        --no-remote-commits) opt_remote="" ;;
        --no-local-commits) opt_local="" ;;
        --no-status) opt_status="" ;;
        --no-stash) opt_stash="" ;;
        --help) usage ;;
        -h) usage ;;
        *) break
    esac
    shift
done

if [ $# -ne 0 ]; then
    printf 'git-sonar: Unrecognized option given: %s\n' "$1"
    exit 1
fi

# git-precheck will determine whether we are currently inside a git repository,
# as well as whether the repo is in any abnormal state.  If outside a repo, exit
# and do not process any remainder of this script.
git-precheck --quiet --ignore-dirty --ignore-untracked >/dev/null 2>&1
precheck_status=$?
[ "$precheck_status" -ge 4 ] && exit 0

# Initialize configuration variables and set default values if not provided by
# the environment.  Note: ANSI escape codes (and other unprintable sequences)
# must be enclosed within a \001 byte (ASCII start of heading) at the start and
# a \002 byte (ASCII start of text) at the end - some shells rely on this to
# correctly track the length of their rendered prompt.
COLOR_GRAY="\001\\033[1;30m\002"
COLOR_RED="\001\\033[1;31m\002"
COLOR_GREEN="\001\\033[1;32m\002"
COLOR_YELLOW="\001\\033[1;33m\002"
COLOR_WHITE="\001\\033[1;37m\002"
COLOR_DEF="\001\\033[0m\002"

FETCH_TIME="${GIT_SONAR_FETCH_TIME:-"300"}"

ALERT_ICON="${GIT_SONAR_ALERT_ICON:-"${COLOR_YELLOW}⚡"}"
STASH_ICON="${GIT_SONAR_STASH_ICON:-"${COLOR_YELLOW}≡"}"
BRANCH_COLOR="${GIT_SONAR_BRANCH_COLOR:-"$COLOR_DEF"}"
STATUS_SEP="${GIT_SONAR_STATUS_SEPARATOR:-" "}"

AHEAD_ICON="${GIT_SONAR_AHEAD_ICON:-"${COLOR_GREEN}↑"}" # "←"
BEHIND_ICON="${GIT_SONAR_BEHIND_ICON:-"${COLOR_RED}↓"}" # "→"
DIVERGED_ICON="${GIT_SONAR_DIVERGED_ICON:-"${COLOR_YELLOW}⇵"}" # "⇄"

STAGED_COLOR="${GIT_SONAR_STAGED_COLOR:-"$COLOR_GREEN"}"
UNSTAGED_COLOR="${GIT_SONAR_UNSTAGED_COLOR:-"$COLOR_RED"}"
UNMERGED_COLOR="${GIT_SONAR_UNMERGED_COLOR:-"$COLOR_YELLOW"}"
UNTRACKED_COLOR="${GIT_SONAR_UNTRACKED_COLOR:-"$COLOR_WHITE"}"

PROMPT_COLOR="${GIT_SONAR_PROMPT_COLOR:-"$COLOR_GRAY"}"
PROMPT_FORMAT="${GIT_SONAR_PROMPT_FORMAT:-" ${PROMPT_COLOR}git:(${COLOR_DEF}%{alert}%{remote: }%{branch}%{ :local}${PROMPT_COLOR})${COLOR_DEF}%{ :stash}%{ :status}"}"

# Gather information about the current git branch.
upstream_name="$(git rev-parse --abbrev-ref '@{upstream}' 2>/dev/null)"
branch_name="$(git symbolic-ref --short HEAD 2>/dev/null)"
commit_hash="$(git rev-parse --short HEAD 2>/dev/null)"

# Helper functions

determine_default_branch() {
    # todo: handle remotes other than 'origin'
    for ref in origin/HEAD origin/master origin/main; do
        if git rev-parse --verify "$ref" >/dev/null 2>&1; then
            echo "$ref"
            break
        fi
    done
}

print_commit_range() {
    if [ -n "$1" ] && [ -n "$2" ]; then
        ahead="$(git rev-list --count "${1}..${2}" 2>/dev/null)" || ahead="0"
        behind="$(git rev-list --count "${2}..${1}" 2>/dev/null)" || behind="0"

        if [ "$behind" -ne 0 ] && [ "$ahead" -ne 0 ]; then
            printf '%s%b%b%s' "$behind" "$DIVERGED_ICON" "$COLOR_DEF" "$ahead"
        elif [ "$behind" -ne 0 ]; then
            printf '%s%b%b' "$behind" "$BEHIND_ICON" "$COLOR_DEF"
        elif [ "$ahead" -ne 0 ]; then
            printf '%b%b%s' "$AHEAD_ICON" "$COLOR_DEF" "$ahead"
        fi
    fi
}

# Prompt elements

element_alert() {
    configured_upstream="$(git config --local "branch.${branch_name}.merge")"
    if { [ -n "$configured_upstream" ] && [ -z "$upstream_name" ]; } \
        || [ "$precheck_status" -ge 3 ]; then
        printf '%b%b' "$ALERT_ICON" "$COLOR_DEF"
    fi
}

element_branch() {
    [ -n "$branch_name" ] && branch="$branch_name" || branch="detached@${commit_hash}"
    printf '%b%s%b' "$BRANCH_COLOR" "$branch" "$COLOR_DEF"
}

element_remote() {
    if [ -n "$opt_remote" ]; then
        default_branch="$(determine_default_branch)"
        print_commit_range "$default_branch" "$upstream_name"
    fi
}

element_local() {
    if [ -n "$opt_local" ]; then
        print_commit_range "$upstream_name" "HEAD"
    fi
}

element_stash() {
    if [ -n "$opt_stash" ]; then
        cnt="$(git rev-list --walk-reflogs --ignore-missing --count refs/stash)"
        if [ "$cnt" -ne 0 ]; then
            printf '%s%b%b' "$cnt" "$STASH_ICON" "$COLOR_DEF"
        fi
    fi
}

element_status() {
    if [ -n "$opt_status" ]; then
        # git status is the most expensive subprocess we call, so place it here
        # to bypass it if $opt_status is "false".  The output is filtered
        # through sed to prevent certain unmerged paths from being double
        # counted as staged/unstaged ("AA"/"DD").
        git_status="$(\
            git status --porcelain 2>/dev/null \
            | sed 's/^AA /AU /;s/^DD /DU /' \
        )"

        gs=""
        st_sep="$STATUS_SEP"
        um_sep="$STATUS_SEP"
        us_sep="$STATUS_SEP"
        ut_sep="$STATUS_SEP"

        # See "man 1 git-status" sections "Short Format" and "Porcelain Format
        # Version 1" for an explanation of these status indicators.

        # Staged
        for x in A M R C D T; do
            if cnt="$(echo "$git_status" | grep -cE "^${x}[^U] ")"; then
                gs="$(printf '%b%b%s%b%s%b' \
                    "$gs" "$st_sep" "$cnt" "$STAGED_COLOR" "$x" "$COLOR_DEF" \
                )"
                st_sep=""
            fi
        done

        # Unmerged, conflicted
        for x in A U D; do
            if cnt="$(echo "$git_status" | grep -cE "^(U${x}|${x}U) ")"; then
                gs="$(printf '%b%b%s%b%s%b' \
                    "$gs" "$um_sep" "$cnt" "$UNMERGED_COLOR" "$x" "$COLOR_DEF" \
                )"
                um_sep=""
            fi
        done

        # Unstaged
        for x in M D T; do # R C omitted
            if cnt="$(echo "$git_status" | grep -cE "^[^U]${x} ")"; then
                gs="$(printf '%b%b%s%b%s%b' \
                    "$gs" "$us_sep" "$cnt" "$UNSTAGED_COLOR" "$x" "$COLOR_DEF" \
                )"
                us_sep=""
            fi
        done

        # Untracked
        if cnt="$(echo "$git_status" | grep -cE "^\?\? ")"; then
            gs="$(printf '%b%b%s%b?%b' \
                "$gs" "$ut_sep" "$cnt" "$UNTRACKED_COLOR" "$COLOR_DEF" \
            )"
            ut_sep=""
        fi

        printf '%b' "${gs#"$STATUS_SEP"}"
    fi
}

# Main functions

SED_PRE="%{\(\([^%^{^}]*\)\:\)\{0,1\}"
SED_POST="\(\:\([^%^{^}]*\)\)\{0,1\}}"

prepare_element() {
    result="$($2 | sed 's/\//\\\//g')"
    if [ -n "$result" ]; then
        printf '%b' "s/${SED_PRE}${1}${SED_POST}/\\\\2${result}\\\\4/"
    else
        printf '%b' "s/${SED_PRE}${1}${SED_POST}//"
    fi
}

# If fetch was requested, manage timestamp and run fetch if necessary
if [ -n "$opt_fetch" ]; then
    tsfile="$(git rev-parse --git-path lastupdatetime 2>/dev/null)"
    now="$(date '+%s')"
    timestamp="$(cat "$tsfile" 2>/dev/null)"
    [ "$timestamp" -eq "$timestamp" ] >/dev/null 2>&1 || timestamp="0"

    if [ "$((now - timestamp))" -ge "$FETCH_TIME" ]; then
        nohup git fetch --quiet >/dev/null 2>&1 &
        echo "$now" >"$tsfile"
    fi
fi

# Render prompt elements from format string
printf '%b' "$PROMPT_FORMAT" | sed \
    -e "$(prepare_element alert element_alert)" \
    -e "$(prepare_element branch element_branch)" \
    -e "$(prepare_element remote element_remote)" \
    -e "$(prepare_element local element_local)" \
    -e "$(prepare_element stash element_stash)" \
    -e "$(prepare_element status element_status)"