changelog.sh 16 KB

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