Parent directory

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