changelog.sh 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573
  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) return 0 ;;
  192. esac
  193. # kitty supports hyperlinks
  194. if [ "$TERM" = xterm-kitty ]; then
  195. return 0
  196. fi
  197. # Windows Terminal also supports hyperlinks
  198. if [ -n "$WT_SESSION" ]; then
  199. return 0
  200. fi
  201. # Konsole supports hyperlinks, but it's an opt-in setting that can't be detected
  202. # https://github.com/ohmyzsh/ohmyzsh/issues/10964
  203. # if [ -n "$KONSOLE_VERSION" ]; then
  204. # return 0
  205. # fi
  206. return 1
  207. }
  208. #############################
  209. # RELEASE CHANGELOG DISPLAY #
  210. #############################
  211. function display-release {
  212. # This function uses the following globals: output, version,
  213. # types (A), subjects (A), scopes (A), breaking (A) and reverts (A).
  214. #
  215. # - output is the output format to use when formatting (raw|text|md)
  216. # - version is the version in which the commits are made
  217. # - types, subjects, scopes, breaking, and reverts are associative arrays
  218. # with commit hashes as keys
  219. # Remove commits that were reverted
  220. local hash rhash
  221. for hash rhash in ${(kv)reverts}; do
  222. if (( ${+types[$rhash]} )); then
  223. # Remove revert commit
  224. unset "types[$hash]" "subjects[$hash]" "scopes[$hash]" "breaking[$hash]"
  225. # Remove reverted commit
  226. unset "types[$rhash]" "subjects[$rhash]" "scopes[$rhash]" "breaking[$rhash]"
  227. fi
  228. done
  229. # Remove commits from ignored types unless it has breaking change information
  230. for hash in ${(k)types[(R)${(j:|:)IGNORED_TYPES}]}; do
  231. (( ! ${+breaking[$hash]} )) || continue
  232. unset "types[$hash]" "subjects[$hash]" "scopes[$hash]"
  233. done
  234. # If no commits left skip displaying the release
  235. if (( $#types == 0 )); then
  236. return
  237. fi
  238. # Get length of longest scope for padding
  239. local max_scope=0
  240. for hash in ${(k)scopes}; do
  241. max_scope=$(( max_scope < ${#scopes[$hash]} ? ${#scopes[$hash]} : max_scope ))
  242. done
  243. ##* Formatting functions
  244. # Format the hash according to output format
  245. # If no parameter is passed, assume it comes from `$hash`
  246. function fmt:hash {
  247. #* Uses $hash from outer scope
  248. local hash="${1:-$hash}"
  249. case "$output" in
  250. raw) printf '%s' "$hash" ;;
  251. text)
  252. local text="\e[33m$hash\e[0m"; # red
  253. if supports_hyperlinks; then
  254. printf "\e]8;;%s\a%s\e]8;;\a" "https://github.com/ohmyzsh/ohmyzsh/commit/$hash" $text;
  255. else
  256. echo $text;
  257. fi ;;
  258. md) printf '[`%s`](https://github.com/ohmyzsh/ohmyzsh/commit/%s)' "$hash" "$hash" ;;
  259. esac
  260. }
  261. # Format headers according to output format
  262. # Levels 1 to 2 are considered special, the rest are formatted
  263. # the same, except in md output format.
  264. function fmt:header {
  265. local header="$1" level="$2"
  266. case "$output" in
  267. raw)
  268. case "$level" in
  269. 1) printf '%s\n%s\n\n' "$header" "$(printf '%.0s=' {1..${#header}})" ;;
  270. 2) printf '%s\n%s\n\n' "$header" "$(printf '%.0s-' {1..${#header}})" ;;
  271. *) printf '%s:\n\n' "$header" ;;
  272. esac ;;
  273. text)
  274. case "$level" in
  275. 1|2) printf '\e[1;4m%s\e[0m\n\n' "$header" ;; # bold, underlined
  276. *) printf '\e[1m%s:\e[0m\n\n' "$header" ;; # bold
  277. esac ;;
  278. md) printf '%s %s\n\n' "$(printf '%.0s#' {1..${level}})" "$header" ;;
  279. esac
  280. }
  281. function fmt:scope {
  282. #* Uses $scopes (A) and $hash from outer scope
  283. local scope="${1:-${scopes[$hash]}}"
  284. # If no scopes, exit the function
  285. if [[ $max_scope -eq 0 ]]; then
  286. return
  287. fi
  288. # Get how much padding is required for this scope
  289. local padding=0
  290. padding=$(( max_scope < ${#scope} ? 0 : max_scope - ${#scope} ))
  291. padding="${(r:$padding:: :):-}"
  292. # If no scope, print padding and 3 spaces (equivalent to "[] ")
  293. if [[ -z "$scope" ]]; then
  294. printf "${padding} "
  295. return
  296. fi
  297. # Print [scope]
  298. case "$output" in
  299. raw|md) printf '[%s]%s ' "$scope" "$padding";;
  300. text) printf '[\e[38;5;9m%s\e[0m]%s ' "$scope" "$padding";; # red 9
  301. esac
  302. }
  303. # If no parameter is passed, assume it comes from `$subjects[$hash]`
  304. function fmt:subject {
  305. #* Uses $subjects (A) and $hash from outer scope
  306. local subject="${1:-${subjects[$hash]}}"
  307. # Capitalize first letter of the subject
  308. subject="${(U)subject:0:1}${subject:1}"
  309. case "$output" in
  310. raw) printf '%s' "$subject" ;;
  311. # In text mode, highlight (#<issue>) and dim text between `backticks`
  312. text)
  313. if supports_hyperlinks; then
  314. 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"
  315. else
  316. sed -E $'s|#([0-9]+)|\e[32m#\\1\e[0m|g;s|`([^`]+)`|`\e[2m\\1\e[0m`|g' <<< "$subject"
  317. fi ;;
  318. # In markdown mode, link to (#<issue>) issues
  319. md) sed -E 's|#([0-9]+)|[#\1](https://github.com/ohmyzsh/ohmyzsh/issues/\1)|g' <<< "$subject" ;;
  320. esac
  321. }
  322. function fmt:type {
  323. #* Uses $type from outer scope
  324. local type="${1:-${TYPES[$type]:-${(C)type}}}"
  325. [[ -z "$type" ]] && return 0
  326. case "$output" in
  327. raw|md) printf '%s: ' "$type" ;;
  328. text) printf '\e[4m%s\e[24m: ' "$type" ;; # underlined
  329. esac
  330. }
  331. ##* Section functions
  332. function display:version {
  333. fmt:header "$version" 2
  334. }
  335. function display:breaking {
  336. (( $#breaking != 0 )) || return 0
  337. case "$output" in
  338. text) printf '\e[31m'; fmt:header "BREAKING CHANGES" 3 ;;
  339. raw) fmt:header "BREAKING CHANGES" 3 ;;
  340. md) fmt:header "BREAKING CHANGES ⚠" 3 ;;
  341. esac
  342. local hash message
  343. local wrap_width=$(( (COLUMNS < 100 ? COLUMNS : 100) - 3 ))
  344. for hash message in ${(kv)breaking}; do
  345. # Format the BREAKING CHANGE message by word-wrapping it at maximum 100
  346. # characters (use $COLUMNS if smaller than 100)
  347. message="$(fmt -w $wrap_width <<< "$message")"
  348. # Display hash and scope in their own line, and then the full message with
  349. # blank lines as separators and a 3-space left padding
  350. echo " - $(fmt:hash) $(fmt:scope)\n\n$(fmt:subject "$message" | sed 's/^/ /')\n"
  351. done
  352. }
  353. function display:type {
  354. local hash type="$1"
  355. local -a hashes
  356. hashes=(${(k)types[(R)$type]})
  357. # If no commits found of type $type, go to next type
  358. (( $#hashes != 0 )) || return 0
  359. fmt:header "${TYPES[$type]}" 3
  360. for hash in $hashes; do
  361. echo " - $(fmt:hash) $(fmt:scope)$(fmt:subject)"
  362. done | sort -k3 # sort by scope
  363. echo
  364. }
  365. function display:others {
  366. local hash type
  367. # Commits made under types considered other changes
  368. local -A changes
  369. changes=(${(kv)types[(R)${(j:|:)OTHER_TYPES}]})
  370. # If no commits found under "other" types, don't display anything
  371. (( $#changes != 0 )) || return 0
  372. fmt:header "Other changes" 3
  373. for hash type in ${(kv)changes}; do
  374. case "$type" in
  375. other) echo " - $(fmt:hash) $(fmt:scope)$(fmt:subject)" ;;
  376. *) echo " - $(fmt:hash) $(fmt:scope)$(fmt:type)$(fmt:subject)" ;;
  377. esac
  378. done | sort -k3 # sort by scope
  379. echo
  380. }
  381. ##* Release sections order
  382. # Display version header
  383. display:version
  384. # Display breaking changes first
  385. display:breaking
  386. # Display changes for commit types in the order specified
  387. for type in $MAIN_TYPES; do
  388. display:type "$type"
  389. done
  390. # Display other changes
  391. display:others
  392. }
  393. function main {
  394. # $1 = until commit, $2 = since commit
  395. local until="$1" since="$2"
  396. # $3 = output format (--text|--raw|--md)
  397. # --md: uses markdown formatting
  398. # --raw: outputs without style
  399. # --text: uses ANSI escape codes to style the output
  400. local output=${${3:-"--text"}#--*}
  401. if [[ -z "$until" ]]; then
  402. until=HEAD
  403. fi
  404. if [[ -z "$since" ]]; then
  405. # If $since is not specified:
  406. # 1) try to find the version used before updating
  407. # 2) try to find the first version tag before $until
  408. since=$(command git config --get oh-my-zsh.lastVersion 2>/dev/null) || \
  409. since=$(command git describe --abbrev=0 --tags "$until^" 2>/dev/null) || \
  410. unset since
  411. elif [[ "$since" = --all ]]; then
  412. unset since
  413. fi
  414. # Commit classification arrays
  415. local -A types subjects scopes breaking reverts
  416. local truncate=0 read_commits=0
  417. local version tag
  418. local hash refs subject body
  419. # Get the first version name:
  420. # 1) try tag-like version, or
  421. # 2) try branch name, or
  422. # 3) try name-rev, or
  423. # 4) try short hash
  424. version=$(command git describe --tags $until 2>/dev/null) \
  425. || version=$(command git symbolic-ref --quiet --short $until 2>/dev/null) \
  426. || version=$(command git name-rev --no-undefined --name-only --exclude="remotes/*" $until 2>/dev/null) \
  427. || version=$(command git rev-parse --short $until 2>/dev/null)
  428. # Get commit list from $until commit until $since commit, or until root commit if $since is unset
  429. local range=${since:+$since..}$until
  430. # Git log options
  431. # -z: commits are delimited by null bytes
  432. # --format: [7-char hash]<field sep>[ref names]<field sep>[subject]<field sep>[body]
  433. # --abbrev=7: force commit hashes to be 7 characters long
  434. # --no-merges: merge commits are omitted
  435. # --first-parent: commits from merged branches are omitted
  436. local SEP="0mZmAgIcSeP"
  437. local -a raw_commits
  438. raw_commits=(${(0)"$(command git -c log.showSignature=false log -z \
  439. --format="%h${SEP}%D${SEP}%s${SEP}%b" --abbrev=7 \
  440. --no-merges --first-parent $range)"})
  441. local raw_commit
  442. local -a raw_fields
  443. for raw_commit in $raw_commits; do
  444. # Truncate list on versions with a lot of commits
  445. if [[ -z "$since" ]] && (( ++read_commits > 35 )); then
  446. truncate=1
  447. break
  448. fi
  449. # Read the commit fields (@ is needed to keep empty values)
  450. eval "raw_fields=(\"\${(@ps:$SEP:)raw_commit}\")"
  451. hash="${raw_fields[1]}"
  452. refs="${raw_fields[2]}"
  453. subject="${raw_fields[3]}"
  454. body="${raw_fields[4]}"
  455. # If we find a new release (exact tag)
  456. if [[ "$refs" = *tag:\ * ]]; then
  457. # Parse tag name (needs: setopt extendedglob)
  458. tag="${${refs##*tag: }%%,# *}"
  459. # Output previous release
  460. display-release
  461. # Reinitialize commit storage
  462. types=()
  463. subjects=()
  464. scopes=()
  465. breaking=()
  466. reverts=()
  467. # Start work on next release
  468. version="$tag"
  469. read_commits=1
  470. fi
  471. parse-commit "$hash" "$subject" "$body"
  472. done
  473. display-release
  474. if (( truncate )); then
  475. echo " ...more commits omitted"
  476. echo
  477. fi
  478. }
  479. # Use raw output if stdout is not a tty
  480. if [[ ! -t 1 && -z "$3" ]]; then
  481. main "$1" "$2" --raw
  482. else
  483. main "$@"
  484. fi