changelog.sh
17592 bytes
1#!/usr/bin/env zsh
2
3cd "$ZSH"
4setopt 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
12local -A TYPES
13TYPES=(
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.
27local -a MAIN_TYPES
28MAIN_TYPES=(feat fix perf docs)
29
30#* Types that will be displayed under the category of other changes
31local -a OTHER_TYPES
32OTHER_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.
36local -a IGNORED_TYPES
37IGNORED_TYPES=(${${${(@k)TYPES}:|MAIN_TYPES}:|OTHER_TYPES})
38
39############################
40# COMMIT PARSING UTILITIES #
41############################
42
43function 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 # remove lines containing only whitespace
110 local nlnl=$'\n\n'
111 message="${message//$'\n'[[:space:]]##$'\n'/$nlnl}"
112 # skip next paragraphs (separated by two newlines or more)
113 message="${message%%$'\n\n'*}"
114 # ... and replace newlines with spaces
115 echo "${message//$'\n'/ }"
116 else
117 return 1
118 fi
119 }
120
121 # Return truncated hash of the reverted commit
122 function commit:is-revert {
123 local subject="$1" body="$2"
124
125 if [[ "$subject" = Revert* && \
126 "$body" =~ "This reverts commit ([^.]+)\." ]]; then
127 echo "${match[1]:0:7}"
128 else
129 return 1
130 fi
131 }
132
133 # Parse commit with hash $1
134 local hash="$1" subject="$2" body="$3" warning rhash
135
136 # Commits following Conventional Commits (https://www.conventionalcommits.org/)
137 # have the following format, where parts between [] are optional:
138 #
139 # type[(scope)][!]: subject
140 #
141 # commit body
142 # [BREAKING CHANGE: warning]
143
144 # commits holds the commit type
145 types[$hash]="$(commit:type "$subject")"
146 # scopes holds the commit scope
147 scopes[$hash]="$(commit:scope "$subject")"
148 # subjects holds the commit subject
149 subjects[$hash]="$(commit:subject "$subject")"
150
151 # breaking holds whether a commit has breaking changes
152 # and its warning message if it does
153 if warning=$(commit:is-breaking "$subject" "$body"); then
154 breaking[$hash]="$warning"
155 fi
156
157 # reverts holds commits reverted in the same release
158 if rhash=$(commit:is-revert "$subject" "$body"); then
159 reverts[$hash]=$rhash
160 fi
161}
162
163################################
164# SUPPORTS HYPERLINKS FUNCTION #
165################################
166
167# The code for checking if a terminal supports hyperlinks is copied from install.sh
168
169# The [ -t 1 ] check only works when the function is not called from
170# a subshell (like in `$(...)` or `(...)`, so this hack redefines the
171# function at the top level to always return false when stdout is not
172# a tty.
173if [ -t 1 ]; then
174 is_tty() {
175 true
176 }
177else
178 is_tty() {
179 false
180 }
181fi
182
183# This function uses the logic from supports-hyperlinks[1][2], which is
184# made by Kat Marchán (@zkat) and licensed under the Apache License 2.0.
185# [1] https://github.com/zkat/supports-hyperlinks
186# [2] https://crates.io/crates/supports-hyperlinks
187#
188# Copyright (c) 2021 Kat Marchán
189#
190# Licensed under the Apache License, Version 2.0 (the "License");
191# you may not use this file except in compliance with the License.
192# You may obtain a copy of the License at
193#
194# http://www.apache.org/licenses/LICENSE-2.0
195#
196# Unless required by applicable law or agreed to in writing, software
197# distributed under the License is distributed on an "AS IS" BASIS,
198# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
199# See the License for the specific language governing permissions and
200# limitations under the License.
201supports_hyperlinks() {
202 # $FORCE_HYPERLINK must be set and be non-zero (this acts as a logic bypass)
203 if [ -n "$FORCE_HYPERLINK" ]; then
204 [ "$FORCE_HYPERLINK" != 0 ]
205 return $?
206 fi
207
208 # If stdout is not a tty, it doesn't support hyperlinks
209 is_tty || return 1
210
211 # DomTerm terminal emulator (domterm.org)
212 if [ -n "$DOMTERM" ]; then
213 return 0
214 fi
215
216 # VTE-based terminals above v0.50 (Gnome Terminal, Guake, ROXTerm, etc)
217 if [ -n "$VTE_VERSION" ]; then
218 [ $VTE_VERSION -ge 5000 ]
219 return $?
220 fi
221
222 # If $TERM_PROGRAM is set, these terminals support hyperlinks
223 case "$TERM_PROGRAM" in
224 ghostty|Hyper|iTerm.app|terminology|vscode|WezTerm) return 0 ;;
225 esac
226
227 # These termcap entries support hyperlinks
228 case "$TERM" in
229 alacritty|alacritty-direct|xterm-ghostty|xterm-kitty) return 0 ;;
230 esac
231
232 # xfce4-terminal supports hyperlinks
233 if [ "$COLORTERM" = "xfce4-terminal" ]; then
234 return 0
235 fi
236
237 # Windows Terminal also supports hyperlinks
238 if [ -n "$WT_SESSION" ]; then
239 return 0
240 fi
241
242 # Konsole supports hyperlinks, but it's an opt-in setting that can't be detected
243 # https://github.com/ohmyzsh/ohmyzsh/issues/10964
244 # if [ -n "$KONSOLE_VERSION" ]; then
245 # return 0
246 # fi
247
248 return 1
249}
250
251#############################
252# RELEASE CHANGELOG DISPLAY #
253#############################
254
255function display-release {
256
257 # This function uses the following globals: output, version,
258 # types (A), subjects (A), scopes (A), breaking (A) and reverts (A).
259 #
260 # - output is the output format to use when formatting (raw|text|md)
261 # - version is the version in which the commits are made
262 # - types, subjects, scopes, breaking, and reverts are associative arrays
263 # with commit hashes as keys
264
265 # Remove commits that were reverted
266 local hash rhash
267 for hash rhash in ${(kv)reverts}; do
268 if (( ${+types[$rhash]} )); then
269 # Remove revert commit
270 unset "types[$hash]" "subjects[$hash]" "scopes[$hash]" "breaking[$hash]"
271 # Remove reverted commit
272 unset "types[$rhash]" "subjects[$rhash]" "scopes[$rhash]" "breaking[$rhash]"
273 fi
274 done
275
276 # Remove commits from ignored types unless it has breaking change information
277 for hash in ${(k)types[(R)${(j:|:)IGNORED_TYPES}]}; do
278 (( ! ${+breaking[$hash]} )) || continue
279 unset "types[$hash]" "subjects[$hash]" "scopes[$hash]"
280 done
281
282 # If no commits left skip displaying the release
283 if (( $#types == 0 )); then
284 return
285 fi
286
287 # Get length of longest scope for padding
288 local max_scope=0
289 for hash in ${(k)scopes}; do
290 max_scope=$(( max_scope < ${#scopes[$hash]} ? ${#scopes[$hash]} : max_scope ))
291 done
292
293 ##* Formatting functions
294
295 # Format the hash according to output format
296 # If no parameter is passed, assume it comes from `$hash`
297 function fmt:hash {
298 #* Uses $hash from outer scope
299 local hash="${1:-$hash}"
300 local short_hash="${hash:0:7}" # 7 characters sha, top level sha is 12 characters
301 case "$output" in
302 raw) printf '%s' "$short_hash" ;;
303 text)
304 local text="\e[33m$short_hash\e[0m"; # red
305 if supports_hyperlinks; then
306 printf "\e]8;;%s\a%s\e]8;;\a" "https://github.com/ohmyzsh/ohmyzsh/commit/$hash" $text;
307 else
308 echo $text;
309 fi ;;
310 md) printf '[`%s`](https://github.com/ohmyzsh/ohmyzsh/commit/%s)' "$short_hash" "$hash" ;;
311 esac
312 }
313
314 # Format headers according to output format
315 # Levels 1 to 2 are considered special, the rest are formatted
316 # the same, except in md output format.
317 function fmt:header {
318 local header="$1" level="$2"
319 case "$output" in
320 raw)
321 case "$level" in
322 1) printf '%s\n%s\n\n' "$header" "$(printf '%.0s=' {1..${#header}})" ;;
323 2) printf '%s\n%s\n\n' "$header" "$(printf '%.0s-' {1..${#header}})" ;;
324 *) printf '%s:\n\n' "$header" ;;
325 esac ;;
326 text)
327 case "$level" in
328 1|2) printf '\e[1;4m%s\e[0m\n\n' "$header" ;; # bold, underlined
329 *) printf '\e[1m%s:\e[0m\n\n' "$header" ;; # bold
330 esac ;;
331 md) printf '%s %s\n\n' "$(printf '%.0s#' {1..${level}})" "$header" ;;
332 esac
333 }
334
335 function fmt:scope {
336 #* Uses $scopes (A) and $hash from outer scope
337 local scope="${1:-${scopes[$hash]}}"
338
339 # If no scopes, exit the function
340 if [[ $max_scope -eq 0 ]]; then
341 return
342 fi
343
344 # Get how much padding is required for this scope
345 local padding=0
346 padding=$(( max_scope < ${#scope} ? 0 : max_scope - ${#scope} ))
347 padding="${(r:$padding:: :):-}"
348
349 # If no scope, print padding and 3 spaces (equivalent to "[] ")
350 if [[ -z "$scope" ]]; then
351 printf "${padding} "
352 return
353 fi
354
355 # Print [scope]
356 case "$output" in
357 raw|md) printf '[%s]%s ' "$scope" "$padding";;
358 text) printf '[\e[38;5;9m%s\e[0m]%s ' "$scope" "$padding";; # red 9
359 esac
360 }
361
362 # If no parameter is passed, assume it comes from `$subjects[$hash]`
363 function fmt:subject {
364 #* Uses $subjects (A) and $hash from outer scope
365 local subject="${1:-${subjects[$hash]}}"
366
367 # Capitalize first letter of the subject
368 subject="${(U)subject:0:1}${subject:1}"
369
370 case "$output" in
371 raw) printf '%s' "$subject" ;;
372 # In text mode, highlight (#<issue>) and dim text between `backticks`
373 text)
374 if supports_hyperlinks; then
375 sed -E $'s|#([0-9]+)|\e]8;;https://github.com/ohmyzsh/ohmyzsh/issues/\\1\a\e[32m#\\1\e[0m\e]8;;\a|g;s|`([^`]+)`|`\e[2m\\1\e[0m`|g' <<< "$subject"
376 else
377 sed -E $'s|#([0-9]+)|\e[32m#\\1\e[0m|g;s|`([^`]+)`|`\e[2m\\1\e[0m`|g' <<< "$subject"
378 fi ;;
379 # In markdown mode, link to (#<issue>) issues
380 md) sed -E 's|#([0-9]+)|[#\1](https://github.com/ohmyzsh/ohmyzsh/issues/\1)|g' <<< "$subject" ;;
381 esac
382 }
383
384 function fmt:type {
385 #* Uses $type from outer scope
386 local type="${1:-${TYPES[$type]:-${(C)type}}}"
387 [[ -z "$type" ]] && return 0
388 case "$output" in
389 raw|md) printf '%s: ' "$type" ;;
390 text) printf '\e[4m%s\e[24m: ' "$type" ;; # underlined
391 esac
392 }
393
394 ##* Section functions
395
396 function display:version {
397 fmt:header "$version" 2
398 }
399
400 function display:breaking {
401 (( $#breaking != 0 )) || return 0
402
403 # If we reach here we have shown commits, set flag
404 shown_commits=1
405
406 case "$output" in
407 text) printf '\e[31m'; fmt:header "BREAKING CHANGES" 3 ;;
408 raw) fmt:header "BREAKING CHANGES" 3 ;;
409 md) fmt:header "BREAKING CHANGES ⚠" 3 ;;
410 esac
411
412 local hash message
413 local wrap_width=$(( (COLUMNS < 100 ? COLUMNS : 100) - 3 ))
414 for hash message in ${(kv)breaking}; do
415 # Format the BREAKING CHANGE message by word-wrapping it at maximum 100
416 # characters (use $COLUMNS if smaller than 100)
417 message="$(fmt -w $wrap_width <<< "$message")"
418 # Display hash and scope in their own line, and then the full message with
419 # blank lines as separators and a 3-space left padding
420 echo " - $(fmt:hash) $(fmt:scope)\n\n$(fmt:subject "$message" | sed 's/^/ /')\n"
421 done
422 }
423
424 function display:type {
425 local hash type="$1"
426
427 local -a hashes
428 hashes=(${(k)types[(R)$type]})
429
430 # If no commits found of type $type, go to next type
431 (( $#hashes != 0 )) || return 0
432
433 # If we reach here we have shown commits, set flag
434 shown_commits=1
435
436 fmt:header "${TYPES[$type]}" 3
437 for hash in $hashes; do
438 echo " - $(fmt:hash) $(fmt:scope)$(fmt:subject)"
439 done | sort -k3 # sort by scope
440 echo
441 }
442
443 function display:others {
444 local hash type
445
446 # Commits made under types considered other changes
447 local -A changes
448 changes=(${(kv)types[(R)${(j:|:)OTHER_TYPES}]})
449
450 # If no commits found under "other" types, don't display anything
451 (( $#changes != 0 )) || return 0
452
453 # If we reach here we have shown commits, set flag
454 shown_commits=1
455
456 fmt:header "Other changes" 3
457 for hash type in ${(kv)changes}; do
458 case "$type" in
459 other) echo " - $(fmt:hash) $(fmt:scope)$(fmt:subject)" ;;
460 *) echo " - $(fmt:hash) $(fmt:scope)$(fmt:type)$(fmt:subject)" ;;
461 esac
462 done | sort -k3 # sort by scope
463 echo
464 }
465
466 ##* Release sections order
467
468 # Display version header
469 display:version
470
471 # Display breaking changes first
472 display:breaking
473
474 # Display changes for commit types in the order specified
475 for type in $MAIN_TYPES; do
476 display:type "$type"
477 done
478
479 # Display other changes
480 display:others
481}
482
483function main {
484 # $1 = until commit, $2 = since commit
485 local until="$1" since="$2"
486
487 # $3 = output format (--text|--raw|--md)
488 # --md: uses markdown formatting
489 # --raw: outputs without style
490 # --text: uses ANSI escape codes to style the output
491 local output=${${3:-"--text"}#--*}
492
493 if [[ -z "$until" ]]; then
494 until=HEAD
495 fi
496
497 if [[ -z "$since" ]]; then
498 # If $since is not specified:
499 # 1) try to find the version used before updating
500 # 2) try to find the first version tag before $until
501 since=$(command git config --get oh-my-zsh.lastVersion 2>/dev/null) || \
502 since=$(command git describe --abbrev=0 --tags "$until^" 2>/dev/null) || \
503 unset since
504 elif [[ "$since" = --all ]]; then
505 unset since
506 fi
507
508 # Commit classification arrays
509 local -A types subjects scopes breaking reverts
510 local truncate=0 read_commits=0 shown_commits=0
511 local version tag
512 local hash refs subject body
513
514 # Get the first version name:
515 # 1) try tag-like version, or
516 # 2) try branch name, or
517 # 3) try name-rev, or
518 # 4) try short hash
519 version=$(command git describe --tags $until 2>/dev/null) \
520 || version=$(command git symbolic-ref --quiet --short $until 2>/dev/null) \
521 || version=$(command git name-rev --no-undefined --name-only --exclude="remotes/*" $until 2>/dev/null) \
522 || version=$(command git rev-parse --short $until 2>/dev/null)
523
524 # Get commit list from $until commit until $since commit, or until root commit if $since is unset
525 local range=${since:+$since..}$until
526
527 # Git log options
528 # -z: commits are delimited by null bytes
529 # --format: [7-char hash]<field sep>[ref names]<field sep>[subject]<field sep>[body]
530 # --abbrev=7: force commit hashes to be 12 characters long
531 # --no-merges: merge commits are omitted
532 # --first-parent: commits from merged branches are omitted
533 local SEP="0mZmAgIcSeP"
534 local -a raw_commits
535 raw_commits=(${(0)"$(command git -c log.showSignature=false log -z \
536 --format="%h${SEP}%D${SEP}%s${SEP}%b" --abbrev=12 \
537 --no-merges --first-parent $range)"})
538
539 local raw_commit
540 local -a raw_fields
541 for raw_commit in $raw_commits; do
542 # Truncate list on versions with a lot of commits
543 if [[ -z "$since" ]] && (( ++read_commits > 35 )); then
544 truncate=1
545 break
546 fi
547
548 # Read the commit fields (@ is needed to keep empty values)
549 eval "raw_fields=(\"\${(@ps:$SEP:)raw_commit}\")"
550 hash="${raw_fields[1]}"
551 refs="${raw_fields[2]}"
552 subject="${raw_fields[3]}"
553 body="${raw_fields[4]}"
554
555 # If we find a new release (exact tag)
556 if [[ "$refs" = *tag:\ * ]]; then
557 # Parse tag name (needs: setopt extendedglob)
558 tag="${${refs##*tag: }%%,# *}"
559 # Output previous release
560 display-release
561 # Reinitialize commit storage
562 types=()
563 subjects=()
564 scopes=()
565 breaking=()
566 reverts=()
567 # Start work on next release
568 version="$tag"
569 read_commits=1
570 fi
571
572 parse-commit "$hash" "$subject" "$body"
573 done
574
575 display-release
576
577 if (( truncate )); then
578 echo " ...more commits omitted"
579 echo
580 fi
581
582 if (( ! shown_commits )); then
583 echo "No changes to mention."
584 fi
585}
586
587# Use raw output if stdout is not a tty
588if [[ ! -t 1 && -z "$3" ]]; then
589 main "$1" "$2" --raw
590else
591 main "$@"
592fi