changelog.sh 17 KB

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