changelog.sh 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579
  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. case "$output" in
  343. text) printf '\e[31m'; fmt:header "BREAKING CHANGES" 3 ;;
  344. raw) fmt:header "BREAKING CHANGES" 3 ;;
  345. md) fmt:header "BREAKING CHANGES ⚠" 3 ;;
  346. esac
  347. local hash message
  348. local wrap_width=$(( (COLUMNS < 100 ? COLUMNS : 100) - 3 ))
  349. for hash message in ${(kv)breaking}; do
  350. # Format the BREAKING CHANGE message by word-wrapping it at maximum 100
  351. # characters (use $COLUMNS if smaller than 100)
  352. message="$(fmt -w $wrap_width <<< "$message")"
  353. # Display hash and scope in their own line, and then the full message with
  354. # blank lines as separators and a 3-space left padding
  355. echo " - $(fmt:hash) $(fmt:scope)\n\n$(fmt:subject "$message" | sed 's/^/ /')\n"
  356. done
  357. }
  358. function display:type {
  359. local hash type="$1"
  360. local -a hashes
  361. hashes=(${(k)types[(R)$type]})
  362. # If no commits found of type $type, go to next type
  363. (( $#hashes != 0 )) || return 0
  364. fmt:header "${TYPES[$type]}" 3
  365. for hash in $hashes; do
  366. echo " - $(fmt:hash) $(fmt:scope)$(fmt:subject)"
  367. done | sort -k3 # sort by scope
  368. echo
  369. }
  370. function display:others {
  371. local hash type
  372. # Commits made under types considered other changes
  373. local -A changes
  374. changes=(${(kv)types[(R)${(j:|:)OTHER_TYPES}]})
  375. # If no commits found under "other" types, don't display anything
  376. (( $#changes != 0 )) || return 0
  377. fmt:header "Other changes" 3
  378. for hash type in ${(kv)changes}; do
  379. case "$type" in
  380. other) echo " - $(fmt:hash) $(fmt:scope)$(fmt:subject)" ;;
  381. *) echo " - $(fmt:hash) $(fmt:scope)$(fmt:type)$(fmt:subject)" ;;
  382. esac
  383. done | sort -k3 # sort by scope
  384. echo
  385. }
  386. ##* Release sections order
  387. # Display version header
  388. display:version
  389. # Display breaking changes first
  390. display:breaking
  391. # Display changes for commit types in the order specified
  392. for type in $MAIN_TYPES; do
  393. display:type "$type"
  394. done
  395. # Display other changes
  396. display:others
  397. }
  398. function main {
  399. # $1 = until commit, $2 = since commit
  400. local until="$1" since="$2"
  401. # $3 = output format (--text|--raw|--md)
  402. # --md: uses markdown formatting
  403. # --raw: outputs without style
  404. # --text: uses ANSI escape codes to style the output
  405. local output=${${3:-"--text"}#--*}
  406. if [[ -z "$until" ]]; then
  407. until=HEAD
  408. fi
  409. if [[ -z "$since" ]]; then
  410. # If $since is not specified:
  411. # 1) try to find the version used before updating
  412. # 2) try to find the first version tag before $until
  413. since=$(command git config --get oh-my-zsh.lastVersion 2>/dev/null) || \
  414. since=$(command git describe --abbrev=0 --tags "$until^" 2>/dev/null) || \
  415. unset since
  416. elif [[ "$since" = --all ]]; then
  417. unset since
  418. fi
  419. # Commit classification arrays
  420. local -A types subjects scopes breaking reverts
  421. local truncate=0 read_commits=0
  422. local version tag
  423. local hash refs subject body
  424. # Get the first version name:
  425. # 1) try tag-like version, or
  426. # 2) try branch name, or
  427. # 3) try name-rev, or
  428. # 4) try short hash
  429. version=$(command git describe --tags $until 2>/dev/null) \
  430. || version=$(command git symbolic-ref --quiet --short $until 2>/dev/null) \
  431. || version=$(command git name-rev --no-undefined --name-only --exclude="remotes/*" $until 2>/dev/null) \
  432. || version=$(command git rev-parse --short $until 2>/dev/null)
  433. # Get commit list from $until commit until $since commit, or until root commit if $since is unset
  434. local range=${since:+$since..}$until
  435. # Git log options
  436. # -z: commits are delimited by null bytes
  437. # --format: [7-char hash]<field sep>[ref names]<field sep>[subject]<field sep>[body]
  438. # --abbrev=7: force commit hashes to be 12 characters long
  439. # --no-merges: merge commits are omitted
  440. # --first-parent: commits from merged branches are omitted
  441. local SEP="0mZmAgIcSeP"
  442. local -a raw_commits
  443. raw_commits=(${(0)"$(command git -c log.showSignature=false log -z \
  444. --format="%h${SEP}%D${SEP}%s${SEP}%b" --abbrev=12 \
  445. --no-merges --first-parent $range)"})
  446. local raw_commit
  447. local -a raw_fields
  448. for raw_commit in $raw_commits; do
  449. # Truncate list on versions with a lot of commits
  450. if [[ -z "$since" ]] && (( ++read_commits > 35 )); then
  451. truncate=1
  452. break
  453. fi
  454. # Read the commit fields (@ is needed to keep empty values)
  455. eval "raw_fields=(\"\${(@ps:$SEP:)raw_commit}\")"
  456. hash="${raw_fields[1]}"
  457. refs="${raw_fields[2]}"
  458. subject="${raw_fields[3]}"
  459. body="${raw_fields[4]}"
  460. # If we find a new release (exact tag)
  461. if [[ "$refs" = *tag:\ * ]]; then
  462. # Parse tag name (needs: setopt extendedglob)
  463. tag="${${refs##*tag: }%%,# *}"
  464. # Output previous release
  465. display-release
  466. # Reinitialize commit storage
  467. types=()
  468. subjects=()
  469. scopes=()
  470. breaking=()
  471. reverts=()
  472. # Start work on next release
  473. version="$tag"
  474. read_commits=1
  475. fi
  476. parse-commit "$hash" "$subject" "$body"
  477. done
  478. display-release
  479. if (( truncate )); then
  480. echo " ...more commits omitted"
  481. echo
  482. fi
  483. }
  484. # Use raw output if stdout is not a tty
  485. if [[ ! -t 1 && -z "$3" ]]; then
  486. main "$1" "$2" --raw
  487. else
  488. main "$@"
  489. fi