changelog.sh 17 KB

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