changelog.sh 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  1. #!/usr/bin/env zsh
  2. ##############################
  3. # CHANGELOG SCRIPT CONSTANTS #
  4. ##############################
  5. #* Holds the list of valid types recognized in a commit subject
  6. #* and the display string of such type
  7. local -A TYPES
  8. TYPES=(
  9. build "Build system"
  10. chore "Chore"
  11. ci "CI"
  12. docs "Documentation"
  13. feat "Features"
  14. fix "Bug fixes"
  15. perf "Performance"
  16. refactor "Refactor"
  17. style "Style"
  18. test "Testing"
  19. )
  20. #* Types that will be displayed in their own section,
  21. #* in the order specified here.
  22. local -a MAIN_TYPES
  23. MAIN_TYPES=(feat fix perf docs)
  24. #* Types that will be displayed under the category of other changes
  25. local -a OTHER_TYPES
  26. OTHER_TYPES=(refactor style other)
  27. #* Commit types that don't appear in $MAIN_TYPES nor $OTHER_TYPES
  28. #* will not be displayed and will simply be ignored.
  29. ############################
  30. # COMMIT PARSING UTILITIES #
  31. ############################
  32. function parse-commit {
  33. # This function uses the following globals as output: commits (A),
  34. # subjects (A), scopes (A) and breaking (A). All associative arrays (A)
  35. # have $hash as the key.
  36. # - commits holds the commit type
  37. # - subjects holds the commit subject
  38. # - scopes holds the scope of a commit
  39. # - breaking holds the breaking change warning if a commit does
  40. # make a breaking change
  41. function commit:type {
  42. local type="$(sed -E 's/^([a-zA-Z_\-]+)(\(.+\))?!?: .+$/\1/' <<< "$1")"
  43. # If $type doesn't appear in $TYPES array mark it as 'other'
  44. if [[ -n "${(k)TYPES[(i)$type]}" ]]; then
  45. echo $type
  46. else
  47. echo other
  48. fi
  49. }
  50. function commit:scope {
  51. local scope
  52. # Try to find scope in "type(<scope>):" format
  53. scope=$(sed -nE 's/^[a-zA-Z_\-]+\((.+)\)!?: .+$/\1/p' <<< "$1")
  54. if [[ -n "$scope" ]]; then
  55. echo "$scope"
  56. return
  57. fi
  58. # If no scope found, try to find it in "<scope>:" format
  59. # Make sure it's not a type before printing it
  60. scope=$(sed -nE 's/^([a-zA-Z_\-]+): .+$/\1/p' <<< "$1")
  61. if [[ -z "${(k)TYPES[(i)$scope]}" ]]; then
  62. echo "$scope"
  63. fi
  64. }
  65. function commit:subject {
  66. # Only display the relevant part of the commit, i.e. if it has the format
  67. # type[(scope)!]: subject, where the part between [] is optional, only
  68. # displays subject. If it doesn't match the format, returns the whole string.
  69. sed -E 's/^[a-zA-Z_\-]+(\(.+\))?!?: (.+)$/\2/' <<< "$1"
  70. }
  71. # Return subject if the body or subject match the breaking change format
  72. function commit:is-breaking {
  73. local subject="$1" body="$2"
  74. if [[ "$body" =~ "BREAKING CHANGE: (.*)" || \
  75. "$subject" =~ '^[^ :\)]+\)?!: (.*)$' ]]; then
  76. echo "${match[1]}"
  77. else
  78. return 1
  79. fi
  80. }
  81. # Return truncated hash of the reverted commit
  82. function commit:is-revert {
  83. local subject="$1" body="$2"
  84. if [[ "$subject" = Revert* && \
  85. "$body" =~ "This reverts commit ([^.]+)\." ]]; then
  86. echo "${match[1]:0:7}"
  87. else
  88. return 1
  89. fi
  90. }
  91. # Parse commit with hash $1
  92. local hash="$1" subject body warning rhash
  93. subject="$(command git show -s --format=%s $hash)"
  94. body="$(command git show -s --format=%b $hash)"
  95. # Commits following Conventional Commits (https://www.conventionalcommits.org/)
  96. # have the following format, where parts between [] are optional:
  97. #
  98. # type[(scope)][!]: subject
  99. #
  100. # commit body
  101. # [BREAKING CHANGE: warning]
  102. # commits holds the commit type
  103. commits[$hash]="$(commit:type "$subject")"
  104. # scopes holds the commit scope
  105. scopes[$hash]="$(commit:scope "$subject")"
  106. # subjects holds the commit subject
  107. subjects[$hash]="$(commit:subject "$subject")"
  108. # breaking holds whether a commit has breaking changes
  109. # and its warning message if it does
  110. if warning=$(commit:is-breaking "$subject" "$body"); then
  111. breaking[$hash]="$warning"
  112. fi
  113. # reverts holds commits reverted in the same release
  114. if rhash=$(commit:is-revert "$subject" "$body"); then
  115. reverts[$hash]=$rhash
  116. fi
  117. }
  118. #############################
  119. # RELEASE CHANGELOG DISPLAY #
  120. #############################
  121. function display-release {
  122. # This function uses the following globals: output, version,
  123. # commits (A), subjects (A), scopes (A), breaking (A) and reverts (A).
  124. #
  125. # - output is the output format to use when formatting (raw|text|md)
  126. # - version is the version in which the commits are made
  127. # - commits, subjects, scopes, breaking, and reverts are associative arrays
  128. # with commit hashes as keys
  129. # Remove commits that were reverted
  130. local hash rhash
  131. for hash rhash in ${(kv)reverts}; do
  132. if (( ${+commits[$rhash]} )); then
  133. # Remove revert commit
  134. unset "commits[$hash]" "subjects[$hash]" "scopes[$hash]" "breaking[$hash]"
  135. # Remove reverted commit
  136. unset "commits[$rhash]" "subjects[$rhash]" "scopes[$rhash]" "breaking[$rhash]"
  137. fi
  138. done
  139. # If no commits left skip displaying the release
  140. if (( $#commits == 0 )); then
  141. return
  142. fi
  143. ##* Formatting functions
  144. # Format the hash according to output format
  145. # If no parameter is passed, assume it comes from `$hash`
  146. function fmt:hash {
  147. #* Uses $hash from outer scope
  148. local hash="${1:-$hash}"
  149. case "$output" in
  150. raw) printf "$hash" ;;
  151. text) printf "\e[33m$hash\e[0m" ;; # red
  152. md) printf "[\`$hash\`](https://github.com/ohmyzsh/ohmyzsh/commit/$hash)" ;;
  153. esac
  154. }
  155. # Format headers according to output format
  156. # Levels 1 to 2 are considered special, the rest are formatted
  157. # the same, except in md output format.
  158. function fmt:header {
  159. local header="$1" level="$2"
  160. case "$output" in
  161. raw)
  162. case "$level" in
  163. 1) printf "$header\n$(printf '%.0s=' {1..${#header}})\n\n" ;;
  164. 2) printf "$header\n$(printf '%.0s-' {1..${#header}})\n\n" ;;
  165. *) printf "$header:\n\n" ;;
  166. esac ;;
  167. text)
  168. case "$level" in
  169. 1|2) printf "\e[1;4m$header\e[0m\n\n" ;; # bold, underlined
  170. *) printf "\e[1m$header:\e[0m\n\n" ;; # bold
  171. esac ;;
  172. md) printf "$(printf '%.0s#' {1..${level}}) $header\n\n" ;;
  173. esac
  174. }
  175. function fmt:scope {
  176. #* Uses $scopes (A) and $hash from outer scope
  177. local scope="${1:-${scopes[$hash]}}"
  178. # Get length of longest scope for padding
  179. local max_scope=0 padding=0
  180. for hash in ${(k)scopes}; do
  181. max_scope=$(( max_scope < ${#scopes[$hash]} ? ${#scopes[$hash]} : max_scope ))
  182. done
  183. # If no scopes, exit the function
  184. if [[ $max_scope -eq 0 ]]; then
  185. return
  186. fi
  187. # Get how much padding is required for this scope
  188. padding=$(( max_scope < ${#scope} ? 0 : max_scope - ${#scope} ))
  189. padding="${(r:$padding:: :):-}"
  190. # If no scope, print padding and 3 spaces (equivalent to "[] ")
  191. if [[ -z "$scope" ]]; then
  192. printf "${padding} "
  193. return
  194. fi
  195. # Print [scope]
  196. case "$output" in
  197. raw|md) printf "[$scope]${padding} " ;;
  198. text) printf "[\e[38;5;9m$scope\e[0m]${padding} " ;; # red 9
  199. esac
  200. }
  201. # If no parameter is passed, assume it comes from `$subjects[$hash]`
  202. function fmt:subject {
  203. #* Uses $subjects (A) and $hash from outer scope
  204. local subject="${1:-${subjects[$hash]}}"
  205. # Capitalize first letter of the subject
  206. subject="${(U)subject:0:1}${subject:1}"
  207. case "$output" in
  208. raw) printf "$subject" ;;
  209. # In text mode, highlight (#<issue>) and dim text between `backticks`
  210. text) sed -E $'s|#([0-9]+)|\e[32m#\\1\e[0m|g;s|`([^`]+)`|`\e[2m\\1\e[0m`|g' <<< "$subject" ;;
  211. # In markdown mode, link to (#<issue>) issues
  212. md) sed -E 's|#([0-9]+)|[#\1](https://github.com/ohmyzsh/ohmyzsh/issues/\1)|g' <<< "$subject" ;;
  213. esac
  214. }
  215. function fmt:type {
  216. #* Uses $type from outer scope
  217. local type="${1:-${TYPES[$type]:-${(C)type}}}"
  218. [[ -z "$type" ]] && return 0
  219. case "$output" in
  220. raw|md) printf "$type: " ;;
  221. text) printf "\e[4m$type\e[24m: " ;; # underlined
  222. esac
  223. }
  224. ##* Section functions
  225. function display:version {
  226. fmt:header "$version" 2
  227. }
  228. function display:breaking {
  229. (( $#breaking != 0 )) || return 0
  230. case "$output" in
  231. raw) fmt:header "BREAKING CHANGES" 3 ;;
  232. text|md) fmt:header "⚠ BREAKING CHANGES" 3 ;;
  233. esac
  234. local hash subject
  235. for hash message in ${(kv)breaking}; do
  236. echo " - $(fmt:hash) $(fmt:subject "${message}")"
  237. done | sort
  238. echo
  239. }
  240. function display:type {
  241. local hash type="$1"
  242. local -a hashes
  243. hashes=(${(k)commits[(R)$type]})
  244. # If no commits found of type $type, go to next type
  245. (( $#hashes != 0 )) || return 0
  246. fmt:header "${TYPES[$type]}" 3
  247. for hash in $hashes; do
  248. echo " - $(fmt:hash) $(fmt:scope)$(fmt:subject)"
  249. done | sort -k3 # sort by scope
  250. echo
  251. }
  252. function display:others {
  253. local hash type
  254. # Commits made under types considered other changes
  255. local -A changes
  256. changes=(${(kv)commits[(R)${(j:|:)OTHER_TYPES}]})
  257. # If no commits found under "other" types, don't display anything
  258. (( $#changes != 0 )) || return 0
  259. fmt:header "Other changes" 3
  260. for hash type in ${(kv)changes}; do
  261. case "$type" in
  262. other) echo " - $(fmt:hash) $(fmt:scope)$(fmt:subject)" ;;
  263. *) echo " - $(fmt:hash) $(fmt:scope)$(fmt:type)$(fmt:subject)" ;;
  264. esac
  265. done | sort -k3 # sort by scope
  266. echo
  267. }
  268. ##* Release sections order
  269. # Display version header
  270. display:version
  271. # Display breaking changes first
  272. display:breaking
  273. # Display changes for commit types in the order specified
  274. for type in $MAIN_TYPES; do
  275. display:type "$type"
  276. done
  277. # Display other changes
  278. display:others
  279. }
  280. function main {
  281. # $1 = until commit, $2 = since commit
  282. # $3 = output format (--raw|--text|--md)
  283. local until="$1" since="$2"
  284. local output=${${3:-"--text"}#--*}
  285. if [[ -z "$until" ]]; then
  286. until=HEAD
  287. fi
  288. # If $since is not specified, look up first version tag before $until
  289. if [[ -z "$since" ]]; then
  290. since=$(command git describe --abbrev=0 --tags "$until^" 2>/dev/null) || \
  291. unset since
  292. elif [[ "$since" = --all ]]; then
  293. unset since
  294. fi
  295. # Commit classification arrays
  296. local -A commits subjects scopes breaking reverts
  297. local truncate=0 read_commits=0
  298. local hash version tag
  299. # Get the first version name:
  300. # 1) try tag-like version, or
  301. # 2) try name-rev, or
  302. # 3) try branch name, or
  303. # 4) try short hash
  304. version=$(command git describe --tags $until 2>/dev/null) \
  305. || version=$(command git name-rev --no-undefined --name-only --exclude="remotes/*" $until 2>/dev/null) \
  306. || version=$(command git symbolic-ref --quiet --short $until 2>/dev/null) \
  307. || version=$(command git rev-parse --short $until 2>/dev/null)
  308. # Get commit list from $until commit until $since commit, or until root
  309. # commit if $since is unset, in short hash form.
  310. # --first-parent is used when dealing with merges: it only prints the
  311. # merge commit, not the commits of the merged branch.
  312. command git rev-list --first-parent --abbrev-commit --abbrev=7 ${since:+$since..}$until | while read hash; do
  313. # Truncate list on versions with a lot of commits
  314. if [[ -z "$since" ]] && (( ++read_commits > 35 )); then
  315. truncate=1
  316. break
  317. fi
  318. # If we find a new release (exact tag)
  319. if tag=$(command git describe --exact-match --tags $hash 2>/dev/null); then
  320. # Output previous release
  321. display-release
  322. # Reinitialize commit storage
  323. commits=()
  324. subjects=()
  325. scopes=()
  326. breaking=()
  327. reverts=()
  328. # Start work on next release
  329. version="$tag"
  330. read_commits=1
  331. fi
  332. parse-commit "$hash"
  333. done
  334. display-release
  335. if (( truncate )); then
  336. echo " ...more commits omitted"
  337. echo
  338. fi
  339. }
  340. cd "$ZSH"
  341. # Use raw output if stdout is not a tty
  342. if [[ ! -t 1 && -z "$3" ]]; then
  343. main "$1" "$2" --raw
  344. else
  345. main "$@"
  346. fi