]> src.twobees.de Git - dotfiles.git/blob - stow/oh-my-zsh/.oh-my-zsh/tools/changelog.sh
...
[dotfiles.git] / stow / oh-my-zsh / .oh-my-zsh / tools / changelog.sh
1 #!/usr/bin/env zsh
2
3 cd "$ZSH"
4 setopt extendedglob
5
6 ##############################
7 # CHANGELOG SCRIPT CONSTANTS #
8 ##############################
9
10 #* Holds the list of valid types recognized in a commit subject
11 #* and the display string of such type
12 local -A TYPES
13 TYPES=(
14   build     "Build system"
15   chore     "Chore"
16   ci        "CI"
17   docs      "Documentation"
18   feat      "Features"
19   fix       "Bug fixes"
20   perf      "Performance"
21   refactor  "Refactor"
22   style     "Style"
23   test      "Testing"
24 )
25
26 #* Types that will be displayed in their own section, in the order specified here.
27 local -a MAIN_TYPES
28 MAIN_TYPES=(feat fix perf docs)
29
30 #* Types that will be displayed under the category of other changes
31 local -a OTHER_TYPES
32 OTHER_TYPES=(refactor style other)
33
34 #* Commit types that don't appear in $MAIN_TYPES nor $OTHER_TYPES
35 #* will not be displayed and will simply be ignored.
36 local -a IGNORED_TYPES
37 IGNORED_TYPES=(${${${(@k)TYPES}:|MAIN_TYPES}:|OTHER_TYPES})
38
39 ############################
40 # COMMIT PARSING UTILITIES #
41 ############################
42
43 function parse-commit {
44
45   # This function uses the following globals as output: commits (A),
46   # subjects (A), scopes (A) and breaking (A). All associative arrays (A)
47   # have $hash as the key.
48   # - commits holds the commit type
49   # - subjects holds the commit subject
50   # - scopes holds the scope of a commit
51   # - breaking holds the breaking change warning if a commit does
52   #   make a breaking change
53
54   function commit:type {
55     local type
56
57     # Parse commit type from the subject
58     if [[ "$1" =~ '^([a-zA-Z_\-]+)(\(.+\))?!?: .+$' ]]; then
59       type="${match[1]}"
60     fi
61
62     # If $type doesn't appear in $TYPES array mark it as 'other'
63     if [[ -n "$type" && -n "${(k)TYPES[(i)$type]}" ]]; then
64       echo $type
65     else
66       echo other
67     fi
68   }
69
70   function commit:scope {
71     local scope
72
73     # Try to find scope in "type(<scope>):" format
74     if [[ "$1" =~ '^[a-zA-Z_\-]+\((.+)\)!?: .+$' ]]; then
75       echo "${match[1]}"
76       return
77     fi
78
79     # If no scope found, try to find it in "<scope>:" format
80     if [[ "$1" =~ '^([a-zA-Z_\-]+): .+$' ]]; then
81       scope="${match[1]}"
82       # Make sure it's not a type before printing it
83       if [[ -z "${(k)TYPES[(i)$scope]}" ]]; then
84         echo "$scope"
85       fi
86     fi
87   }
88
89   function commit:subject {
90     # Only display the relevant part of the commit, i.e. if it has the format
91     # type[(scope)!]: subject, where the part between [] is optional, only
92     # displays subject. If it doesn't match the format, returns the whole string.
93     if [[ "$1" =~ '^[a-zA-Z_\-]+(\(.+\))?!?: (.+)$' ]]; then
94       echo "${match[2]}"
95     else
96       echo "$1"
97     fi
98   }
99
100   # Return subject if the body or subject match the breaking change format
101   function commit:is-breaking {
102     local subject="$1" body="$2" message
103
104     if [[ "$body" =~ "BREAKING CHANGE: (.*)" || \
105       "$subject" =~ '^[^ :\)]+\)?!: (.*)$' ]]; then
106       message="${match[1]}"
107       # remove CR characters (might be inserted in GitHub UI commit description form)
108       message="${message//$'\r'/}"
109       # skip next paragraphs (separated by two newlines or more)
110       message="${message%%$'\n\n'*}"
111       # ... and replace newlines with spaces
112       echo "${message//$'\n'/ }"
113     else
114       return 1
115     fi
116   }
117
118   # Return truncated hash of the reverted commit
119   function commit:is-revert {
120     local subject="$1" body="$2"
121
122     if [[ "$subject" = Revert* && \
123       "$body" =~ "This reverts commit ([^.]+)\." ]]; then
124       echo "${match[1]:0:7}"
125     else
126       return 1
127     fi
128   }
129
130   # Parse commit with hash $1
131   local hash="$1" subject="$2" body="$3" warning rhash
132
133   # Commits following Conventional Commits (https://www.conventionalcommits.org/)
134   # have the following format, where parts between [] are optional:
135   #
136   #  type[(scope)][!]: subject
137   #
138   #  commit body
139   #  [BREAKING CHANGE: warning]
140
141   # commits holds the commit type
142   types[$hash]="$(commit:type "$subject")"
143   # scopes holds the commit scope
144   scopes[$hash]="$(commit:scope "$subject")"
145   # subjects holds the commit subject
146   subjects[$hash]="$(commit:subject "$subject")"
147
148   # breaking holds whether a commit has breaking changes
149   # and its warning message if it does
150   if warning=$(commit:is-breaking "$subject" "$body"); then
151     breaking[$hash]="$warning"
152   fi
153
154   # reverts holds commits reverted in the same release
155   if rhash=$(commit:is-revert "$subject" "$body"); then
156     reverts[$hash]=$rhash
157   fi
158 }
159
160 #############################
161 # RELEASE CHANGELOG DISPLAY #
162 #############################
163
164 function display-release {
165
166   # This function uses the following globals: output, version,
167   # types (A), subjects (A), scopes (A), breaking (A) and reverts (A).
168   #
169   # - output is the output format to use when formatting (raw|text|md)
170   # - version is the version in which the commits are made
171   # - types, subjects, scopes, breaking, and reverts are associative arrays
172   #   with commit hashes as keys
173
174   # Remove commits that were reverted
175   local hash rhash
176   for hash rhash in ${(kv)reverts}; do
177     if (( ${+types[$rhash]} )); then
178       # Remove revert commit
179       unset "types[$hash]" "subjects[$hash]" "scopes[$hash]" "breaking[$hash]"
180       # Remove reverted commit
181       unset "types[$rhash]" "subjects[$rhash]" "scopes[$rhash]" "breaking[$rhash]"
182     fi
183   done
184
185   # Remove commits from ignored types unless it has breaking change information
186   for hash in ${(k)types[(R)${(j:|:)IGNORED_TYPES}]}; do
187     (( ! ${+breaking[$hash]} )) || continue
188     unset "types[$hash]" "subjects[$hash]" "scopes[$hash]"
189   done
190
191   # If no commits left skip displaying the release
192   if (( $#types == 0 )); then
193     return
194   fi
195
196   # Get length of longest scope for padding
197   local max_scope=0
198   for hash in ${(k)scopes}; do
199     max_scope=$(( max_scope < ${#scopes[$hash]} ? ${#scopes[$hash]} : max_scope ))
200   done
201
202   ##* Formatting functions
203
204   # Format the hash according to output format
205   # If no parameter is passed, assume it comes from `$hash`
206   function fmt:hash {
207     #* Uses $hash from outer scope
208     local hash="${1:-$hash}"
209     case "$output" in
210     raw) printf '%s' "$hash" ;;
211     text) printf '\e[33m%s\e[0m' "$hash" ;; # red
212     md) printf '[`%s`](https://github.com/ohmyzsh/ohmyzsh/commit/%s)' "$hash" ;;
213     esac
214   }
215
216   # Format headers according to output format
217   # Levels 1 to 2 are considered special, the rest are formatted
218   # the same, except in md output format.
219   function fmt:header {
220     local header="$1" level="$2"
221     case "$output" in
222     raw)
223       case "$level" in
224       1) printf '%s\n%s\n\n' "$header" "$(printf '%.0s=' {1..${#header}})" ;;
225       2) printf '%s\n%s\n\n' "$header" "$(printf '%.0s-' {1..${#header}})" ;;
226       *) printf '%s:\n\n' "$header" ;;
227       esac ;;
228     text)
229       case "$level" in
230       1|2) printf '\e[1;4m%s\e[0m\n\n' "$header" ;; # bold, underlined
231       *) printf '\e[1m%s:\e[0m\n\n' "$header" ;; # bold
232       esac ;;
233     md) printf '%s %s\n\n' "$(printf '%.0s#' {1..${level}})" "$header" ;;
234     esac
235   }
236
237   function fmt:scope {
238     #* Uses $scopes (A) and $hash from outer scope
239     local scope="${1:-${scopes[$hash]}}"
240
241     # If no scopes, exit the function
242     if [[ $max_scope -eq 0 ]]; then
243       return
244     fi
245
246     # Get how much padding is required for this scope
247     local padding=0
248     padding=$(( max_scope < ${#scope} ? 0 : max_scope - ${#scope} ))
249     padding="${(r:$padding:: :):-}"
250
251     # If no scope, print padding and 3 spaces (equivalent to "[] ")
252     if [[ -z "$scope" ]]; then
253       printf "${padding}   "
254       return
255     fi
256
257     # Print [scope]
258     case "$output" in
259     raw|md) printf '[%s]%s ' "$scope" "$padding";;
260     text) printf '[\e[38;5;9m%s\e[0m]%s ' "$scope" "$padding";; # red 9
261     esac
262   }
263
264   # If no parameter is passed, assume it comes from `$subjects[$hash]`
265   function fmt:subject {
266     #* Uses $subjects (A) and $hash from outer scope
267     local subject="${1:-${subjects[$hash]}}"
268
269     # Capitalize first letter of the subject
270     subject="${(U)subject:0:1}${subject:1}"
271
272     case "$output" in
273     raw) printf '%s' "$subject" ;;
274     # In text mode, highlight (#<issue>) and dim text between `backticks`
275     text) sed -E $'s|#([0-9]+)|\e[32m#\\1\e[0m|g;s|`([^`]+)`|`\e[2m\\1\e[0m`|g' <<< "$subject" ;;
276     # In markdown mode, link to (#<issue>) issues
277     md) sed -E 's|#([0-9]+)|[#\1](https://github.com/ohmyzsh/ohmyzsh/issues/\1)|g' <<< "$subject" ;;
278     esac
279   }
280
281   function fmt:type {
282     #* Uses $type from outer scope
283     local type="${1:-${TYPES[$type]:-${(C)type}}}"
284     [[ -z "$type" ]] && return 0
285     case "$output" in
286     raw|md) printf '%s: ' "$type" ;;
287     text) printf '\e[4m%s\e[24m: ' "$type" ;; # underlined
288     esac
289   }
290
291   ##* Section functions
292
293   function display:version {
294     fmt:header "$version" 2
295   }
296
297   function display:breaking {
298     (( $#breaking != 0 )) || return 0
299
300     case "$output" in
301     text) printf '\e[31m'; fmt:header "BREAKING CHANGES" 3 ;;
302     raw) fmt:header "BREAKING CHANGES" 3 ;;
303     md) fmt:header "BREAKING CHANGES ⚠" 3 ;;
304     esac
305
306     local hash message
307     local wrap_width=$(( (COLUMNS < 100 ? COLUMNS : 100) - 3 ))
308     for hash message in ${(kv)breaking}; do
309       # Format the BREAKING CHANGE message by word-wrapping it at maximum 100
310       # characters (use $COLUMNS if smaller than 100)
311       message="$(fmt -w $wrap_width <<< "$message")"
312       # Display hash and scope in their own line, and then the full message with
313       # blank lines as separators and a 3-space left padding
314       echo " - $(fmt:hash) $(fmt:scope)\n\n$(fmt:subject "$message" | sed 's/^/   /')\n"
315     done
316   }
317
318   function display:type {
319     local hash type="$1"
320
321     local -a hashes
322     hashes=(${(k)types[(R)$type]})
323
324     # If no commits found of type $type, go to next type
325     (( $#hashes != 0 )) || return 0
326
327     fmt:header "${TYPES[$type]}" 3
328     for hash in $hashes; do
329       echo " - $(fmt:hash) $(fmt:scope)$(fmt:subject)"
330     done | sort -k3 # sort by scope
331     echo
332   }
333
334   function display:others {
335     local hash type
336
337     # Commits made under types considered other changes
338     local -A changes
339     changes=(${(kv)types[(R)${(j:|:)OTHER_TYPES}]})
340
341     # If no commits found under "other" types, don't display anything
342     (( $#changes != 0 )) || return 0
343
344     fmt:header "Other changes" 3
345     for hash type in ${(kv)changes}; do
346       case "$type" in
347       other) echo " - $(fmt:hash) $(fmt:scope)$(fmt:subject)" ;;
348       *) echo " - $(fmt:hash) $(fmt:scope)$(fmt:type)$(fmt:subject)" ;;
349       esac
350     done | sort -k3 # sort by scope
351     echo
352   }
353
354   ##* Release sections order
355
356   # Display version header
357   display:version
358
359   # Display breaking changes first
360   display:breaking
361
362   # Display changes for commit types in the order specified
363   for type in $MAIN_TYPES; do
364     display:type "$type"
365   done
366
367   # Display other changes
368   display:others
369 }
370
371 function main {
372   # $1 = until commit, $2 = since commit
373   local until="$1" since="$2"
374
375   # $3 = output format (--text|--raw|--md)
376   # --md:   uses markdown formatting
377   # --raw:  outputs without style
378   # --text: uses ANSI escape codes to style the output
379   local output=${${3:-"--text"}#--*}
380
381   if [[ -z "$until" ]]; then
382     until=HEAD
383   fi
384
385   if [[ -z "$since" ]]; then
386     # If $since is not specified:
387     # 1) try to find the version used before updating
388     # 2) try to find the first version tag before $until
389     since=$(command git config --get oh-my-zsh.lastVersion 2>/dev/null) || \
390     since=$(command git describe --abbrev=0 --tags "$until^" 2>/dev/null) || \
391     unset since
392   elif [[ "$since" = --all ]]; then
393     unset since
394   fi
395
396   # Commit classification arrays
397   local -A types subjects scopes breaking reverts
398   local truncate=0 read_commits=0
399   local version tag
400   local hash refs subject body
401
402   # Get the first version name:
403   # 1) try tag-like version, or
404   # 2) try branch name, or
405   # 3) try name-rev, or
406   # 4) try short hash
407   version=$(command git describe --tags $until 2>/dev/null) \
408     || version=$(command git symbolic-ref --quiet --short $until 2>/dev/null) \
409     || version=$(command git name-rev --no-undefined --name-only --exclude="remotes/*" $until 2>/dev/null) \
410     || version=$(command git rev-parse --short $until 2>/dev/null)
411
412   # Get commit list from $until commit until $since commit, or until root commit if $since is unset
413   local range=${since:+$since..}$until
414
415   # Git log options
416   # -z:             commits are delimited by null bytes
417   # --format:       [7-char hash]<field sep>[ref names]<field sep>[subject]<field sep>[body]
418   # --abbrev=7:     force commit hashes to be 7 characters long
419   # --no-merges:    merge commits are omitted
420   # --first-parent: commits from merged branches are omitted
421   local SEP="0mZmAgIcSeP"
422   local -a raw_commits
423   raw_commits=(${(0)"$(command git -c log.showSignature=false log -z \
424     --format="%h${SEP}%D${SEP}%s${SEP}%b" --abbrev=7 \
425     --no-merges --first-parent $range)"})
426
427   local raw_commit
428   local -a raw_fields
429   for raw_commit in $raw_commits; do
430     # Truncate list on versions with a lot of commits
431     if [[ -z "$since" ]] && (( ++read_commits > 35 )); then
432       truncate=1
433       break
434     fi
435
436     # Read the commit fields (@ is needed to keep empty values)
437     eval "raw_fields=(\"\${(@ps:$SEP:)raw_commit}\")"
438     hash="${raw_fields[1]}"
439     refs="${raw_fields[2]}"
440     subject="${raw_fields[3]}"
441     body="${raw_fields[4]}"
442
443     # If we find a new release (exact tag)
444     if [[ "$refs" = *tag:\ * ]]; then
445       # Parse tag name (needs: setopt extendedglob)
446       tag="${${refs##*tag: }%%,# *}"
447       # Output previous release
448       display-release
449       # Reinitialize commit storage
450       types=()
451       subjects=()
452       scopes=()
453       breaking=()
454       reverts=()
455       # Start work on next release
456       version="$tag"
457       read_commits=1
458     fi
459
460     parse-commit "$hash" "$subject" "$body"
461   done
462
463   display-release
464
465   if (( truncate )); then
466     echo " ...more commits omitted"
467     echo
468   fi
469 }
470
471 # Use raw output if stdout is not a tty
472 if [[ ! -t 1 && -z "$3" ]]; then
473   main "$1" "$2" --raw
474 else
475   main "$@"
476 fi