changelog.sh 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476
  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. # RELEASE CHANGELOG DISPLAY #
  136. #############################
  137. function display-release {
  138. # This function uses the following globals: output, version,
  139. # types (A), subjects (A), scopes (A), breaking (A) and reverts (A).
  140. #
  141. # - output is the output format to use when formatting (raw|text|md)
  142. # - version is the version in which the commits are made
  143. # - types, subjects, scopes, breaking, and reverts are associative arrays
  144. # with commit hashes as keys
  145. # Remove commits that were reverted
  146. local hash rhash
  147. for hash rhash in ${(kv)reverts}; do
  148. if (( ${+types[$rhash]} )); then
  149. # Remove revert commit
  150. unset "types[$hash]" "subjects[$hash]" "scopes[$hash]" "breaking[$hash]"
  151. # Remove reverted commit
  152. unset "types[$rhash]" "subjects[$rhash]" "scopes[$rhash]" "breaking[$rhash]"
  153. fi
  154. done
  155. # Remove commits from ignored types unless it has breaking change information
  156. for hash in ${(k)types[(R)${(j:|:)IGNORED_TYPES}]}; do
  157. (( ! ${+breaking[$hash]} )) || continue
  158. unset "types[$hash]" "subjects[$hash]" "scopes[$hash]"
  159. done
  160. # If no commits left skip displaying the release
  161. if (( $#types == 0 )); then
  162. return
  163. fi
  164. # Get length of longest scope for padding
  165. local max_scope=0
  166. for hash in ${(k)scopes}; do
  167. max_scope=$(( max_scope < ${#scopes[$hash]} ? ${#scopes[$hash]} : max_scope ))
  168. done
  169. ##* Formatting functions
  170. # Format the hash according to output format
  171. # If no parameter is passed, assume it comes from `$hash`
  172. function fmt:hash {
  173. #* Uses $hash from outer scope
  174. local hash="${1:-$hash}"
  175. case "$output" in
  176. raw) printf '%s' "$hash" ;;
  177. text) printf '\e[33m%s\e[0m' "$hash" ;; # red
  178. md) printf '[`%s`](https://github.com/ohmyzsh/ohmyzsh/commit/%s)' "$hash" "$hash" ;;
  179. esac
  180. }
  181. # Format headers according to output format
  182. # Levels 1 to 2 are considered special, the rest are formatted
  183. # the same, except in md output format.
  184. function fmt:header {
  185. local header="$1" level="$2"
  186. case "$output" in
  187. raw)
  188. case "$level" in
  189. 1) printf '%s\n%s\n\n' "$header" "$(printf '%.0s=' {1..${#header}})" ;;
  190. 2) printf '%s\n%s\n\n' "$header" "$(printf '%.0s-' {1..${#header}})" ;;
  191. *) printf '%s:\n\n' "$header" ;;
  192. esac ;;
  193. text)
  194. case "$level" in
  195. 1|2) printf '\e[1;4m%s\e[0m\n\n' "$header" ;; # bold, underlined
  196. *) printf '\e[1m%s:\e[0m\n\n' "$header" ;; # bold
  197. esac ;;
  198. md) printf '%s %s\n\n' "$(printf '%.0s#' {1..${level}})" "$header" ;;
  199. esac
  200. }
  201. function fmt:scope {
  202. #* Uses $scopes (A) and $hash from outer scope
  203. local scope="${1:-${scopes[$hash]}}"
  204. # If no scopes, exit the function
  205. if [[ $max_scope -eq 0 ]]; then
  206. return
  207. fi
  208. # Get how much padding is required for this scope
  209. local padding=0
  210. padding=$(( max_scope < ${#scope} ? 0 : max_scope - ${#scope} ))
  211. padding="${(r:$padding:: :):-}"
  212. # If no scope, print padding and 3 spaces (equivalent to "[] ")
  213. if [[ -z "$scope" ]]; then
  214. printf "${padding} "
  215. return
  216. fi
  217. # Print [scope]
  218. case "$output" in
  219. raw|md) printf '[%s]%s ' "$scope" "$padding";;
  220. text) printf '[\e[38;5;9m%s\e[0m]%s ' "$scope" "$padding";; # red 9
  221. esac
  222. }
  223. # If no parameter is passed, assume it comes from `$subjects[$hash]`
  224. function fmt:subject {
  225. #* Uses $subjects (A) and $hash from outer scope
  226. local subject="${1:-${subjects[$hash]}}"
  227. # Capitalize first letter of the subject
  228. subject="${(U)subject:0:1}${subject:1}"
  229. case "$output" in
  230. raw) printf '%s' "$subject" ;;
  231. # In text mode, highlight (#<issue>) and dim text between `backticks`
  232. text) sed -E $'s|#([0-9]+)|\e[32m#\\1\e[0m|g;s|`([^`]+)`|`\e[2m\\1\e[0m`|g' <<< "$subject" ;;
  233. # In markdown mode, link to (#<issue>) issues
  234. md) sed -E 's|#([0-9]+)|[#\1](https://github.com/ohmyzsh/ohmyzsh/issues/\1)|g' <<< "$subject" ;;
  235. esac
  236. }
  237. function fmt:type {
  238. #* Uses $type from outer scope
  239. local type="${1:-${TYPES[$type]:-${(C)type}}}"
  240. [[ -z "$type" ]] && return 0
  241. case "$output" in
  242. raw|md) printf '%s: ' "$type" ;;
  243. text) printf '\e[4m%s\e[24m: ' "$type" ;; # underlined
  244. esac
  245. }
  246. ##* Section functions
  247. function display:version {
  248. fmt:header "$version" 2
  249. }
  250. function display:breaking {
  251. (( $#breaking != 0 )) || return 0
  252. case "$output" in
  253. text) printf '\e[31m'; fmt:header "BREAKING CHANGES" 3 ;;
  254. raw) fmt:header "BREAKING CHANGES" 3 ;;
  255. md) fmt:header "BREAKING CHANGES ⚠" 3 ;;
  256. esac
  257. local hash message
  258. local wrap_width=$(( (COLUMNS < 100 ? COLUMNS : 100) - 3 ))
  259. for hash message in ${(kv)breaking}; do
  260. # Format the BREAKING CHANGE message by word-wrapping it at maximum 100
  261. # characters (use $COLUMNS if smaller than 100)
  262. message="$(fmt -w $wrap_width <<< "$message")"
  263. # Display hash and scope in their own line, and then the full message with
  264. # blank lines as separators and a 3-space left padding
  265. echo " - $(fmt:hash) $(fmt:scope)\n\n$(fmt:subject "$message" | sed 's/^/ /')\n"
  266. done
  267. }
  268. function display:type {
  269. local hash type="$1"
  270. local -a hashes
  271. hashes=(${(k)types[(R)$type]})
  272. # If no commits found of type $type, go to next type
  273. (( $#hashes != 0 )) || return 0
  274. fmt:header "${TYPES[$type]}" 3
  275. for hash in $hashes; do
  276. echo " - $(fmt:hash) $(fmt:scope)$(fmt:subject)"
  277. done | sort -k3 # sort by scope
  278. echo
  279. }
  280. function display:others {
  281. local hash type
  282. # Commits made under types considered other changes
  283. local -A changes
  284. changes=(${(kv)types[(R)${(j:|:)OTHER_TYPES}]})
  285. # If no commits found under "other" types, don't display anything
  286. (( $#changes != 0 )) || return 0
  287. fmt:header "Other changes" 3
  288. for hash type in ${(kv)changes}; do
  289. case "$type" in
  290. other) echo " - $(fmt:hash) $(fmt:scope)$(fmt:subject)" ;;
  291. *) echo " - $(fmt:hash) $(fmt:scope)$(fmt:type)$(fmt:subject)" ;;
  292. esac
  293. done | sort -k3 # sort by scope
  294. echo
  295. }
  296. ##* Release sections order
  297. # Display version header
  298. display:version
  299. # Display breaking changes first
  300. display:breaking
  301. # Display changes for commit types in the order specified
  302. for type in $MAIN_TYPES; do
  303. display:type "$type"
  304. done
  305. # Display other changes
  306. display:others
  307. }
  308. function main {
  309. # $1 = until commit, $2 = since commit
  310. local until="$1" since="$2"
  311. # $3 = output format (--text|--raw|--md)
  312. # --md: uses markdown formatting
  313. # --raw: outputs without style
  314. # --text: uses ANSI escape codes to style the output
  315. local output=${${3:-"--text"}#--*}
  316. if [[ -z "$until" ]]; then
  317. until=HEAD
  318. fi
  319. if [[ -z "$since" ]]; then
  320. # If $since is not specified:
  321. # 1) try to find the version used before updating
  322. # 2) try to find the first version tag before $until
  323. since=$(command git config --get oh-my-zsh.lastVersion 2>/dev/null) || \
  324. since=$(command git describe --abbrev=0 --tags "$until^" 2>/dev/null) || \
  325. unset since
  326. elif [[ "$since" = --all ]]; then
  327. unset since
  328. fi
  329. # Commit classification arrays
  330. local -A types subjects scopes breaking reverts
  331. local truncate=0 read_commits=0
  332. local version tag
  333. local hash refs subject body
  334. # Get the first version name:
  335. # 1) try tag-like version, or
  336. # 2) try branch name, or
  337. # 3) try name-rev, or
  338. # 4) try short hash
  339. version=$(command git describe --tags $until 2>/dev/null) \
  340. || version=$(command git symbolic-ref --quiet --short $until 2>/dev/null) \
  341. || version=$(command git name-rev --no-undefined --name-only --exclude="remotes/*" $until 2>/dev/null) \
  342. || version=$(command git rev-parse --short $until 2>/dev/null)
  343. # Get commit list from $until commit until $since commit, or until root commit if $since is unset
  344. local range=${since:+$since..}$until
  345. # Git log options
  346. # -z: commits are delimited by null bytes
  347. # --format: [7-char hash]<field sep>[ref names]<field sep>[subject]<field sep>[body]
  348. # --abbrev=7: force commit hashes to be 7 characters long
  349. # --no-merges: merge commits are omitted
  350. # --first-parent: commits from merged branches are omitted
  351. local SEP="0mZmAgIcSeP"
  352. local -a raw_commits
  353. raw_commits=(${(0)"$(command git -c log.showSignature=false log -z \
  354. --format="%h${SEP}%D${SEP}%s${SEP}%b" --abbrev=7 \
  355. --no-merges --first-parent $range)"})
  356. local raw_commit
  357. local -a raw_fields
  358. for raw_commit in $raw_commits; do
  359. # Truncate list on versions with a lot of commits
  360. if [[ -z "$since" ]] && (( ++read_commits > 35 )); then
  361. truncate=1
  362. break
  363. fi
  364. # Read the commit fields (@ is needed to keep empty values)
  365. eval "raw_fields=(\"\${(@ps:$SEP:)raw_commit}\")"
  366. hash="${raw_fields[1]}"
  367. refs="${raw_fields[2]}"
  368. subject="${raw_fields[3]}"
  369. body="${raw_fields[4]}"
  370. # If we find a new release (exact tag)
  371. if [[ "$refs" = *tag:\ * ]]; then
  372. # Parse tag name (needs: setopt extendedglob)
  373. tag="${${refs##*tag: }%%,# *}"
  374. # Output previous release
  375. display-release
  376. # Reinitialize commit storage
  377. types=()
  378. subjects=()
  379. scopes=()
  380. breaking=()
  381. reverts=()
  382. # Start work on next release
  383. version="$tag"
  384. read_commits=1
  385. fi
  386. parse-commit "$hash" "$subject" "$body"
  387. done
  388. display-release
  389. if (( truncate )); then
  390. echo " ...more commits omitted"
  391. echo
  392. fi
  393. }
  394. # Use raw output if stdout is not a tty
  395. if [[ ! -t 1 && -z "$3" ]]; then
  396. main "$1" "$2" --raw
  397. else
  398. main "$@"
  399. fi