changelog.sh 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429
  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" message
  74. if [[ "$body" =~ "BREAKING CHANGE: (.*)" || \
  75. "$subject" =~ '^[^ :\)]+\)?!: (.*)$' ]]; then
  76. message="${match[1]}"
  77. # skip next paragraphs (separated by two newlines or more)
  78. message="${message%%$'\n\n'*}"
  79. # ... and replace newlines with spaces
  80. echo "${message//$'\n'/ }"
  81. else
  82. return 1
  83. fi
  84. }
  85. # Return truncated hash of the reverted commit
  86. function commit:is-revert {
  87. local subject="$1" body="$2"
  88. if [[ "$subject" = Revert* && \
  89. "$body" =~ "This reverts commit ([^.]+)\." ]]; then
  90. echo "${match[1]:0:7}"
  91. else
  92. return 1
  93. fi
  94. }
  95. # Parse commit with hash $1
  96. local hash="$1" subject body warning rhash
  97. subject="$(command git show -s --format=%s $hash)"
  98. body="$(command git show -s --format=%b $hash)"
  99. # Commits following Conventional Commits (https://www.conventionalcommits.org/)
  100. # have the following format, where parts between [] are optional:
  101. #
  102. # type[(scope)][!]: subject
  103. #
  104. # commit body
  105. # [BREAKING CHANGE: warning]
  106. # commits holds the commit type
  107. commits[$hash]="$(commit:type "$subject")"
  108. # scopes holds the commit scope
  109. scopes[$hash]="$(commit:scope "$subject")"
  110. # subjects holds the commit subject
  111. subjects[$hash]="$(commit:subject "$subject")"
  112. # breaking holds whether a commit has breaking changes
  113. # and its warning message if it does
  114. if warning=$(commit:is-breaking "$subject" "$body"); then
  115. breaking[$hash]="$warning"
  116. fi
  117. # reverts holds commits reverted in the same release
  118. if rhash=$(commit:is-revert "$subject" "$body"); then
  119. reverts[$hash]=$rhash
  120. fi
  121. }
  122. #############################
  123. # RELEASE CHANGELOG DISPLAY #
  124. #############################
  125. function display-release {
  126. # This function uses the following globals: output, version,
  127. # commits (A), subjects (A), scopes (A), breaking (A) and reverts (A).
  128. #
  129. # - output is the output format to use when formatting (raw|text|md)
  130. # - version is the version in which the commits are made
  131. # - commits, subjects, scopes, breaking, and reverts are associative arrays
  132. # with commit hashes as keys
  133. # Remove commits that were reverted
  134. local hash rhash
  135. for hash rhash in ${(kv)reverts}; do
  136. if (( ${+commits[$rhash]} )); then
  137. # Remove revert commit
  138. unset "commits[$hash]" "subjects[$hash]" "scopes[$hash]" "breaking[$hash]"
  139. # Remove reverted commit
  140. unset "commits[$rhash]" "subjects[$rhash]" "scopes[$rhash]" "breaking[$rhash]"
  141. fi
  142. done
  143. # If no commits left skip displaying the release
  144. if (( $#commits == 0 )); then
  145. return
  146. fi
  147. ##* Formatting functions
  148. # Format the hash according to output format
  149. # If no parameter is passed, assume it comes from `$hash`
  150. function fmt:hash {
  151. #* Uses $hash from outer scope
  152. local hash="${1:-$hash}"
  153. case "$output" in
  154. raw) printf "$hash" ;;
  155. text) printf "\e[33m$hash\e[0m" ;; # red
  156. md) printf "[\`$hash\`](https://github.com/ohmyzsh/ohmyzsh/commit/$hash)" ;;
  157. esac
  158. }
  159. # Format headers according to output format
  160. # Levels 1 to 2 are considered special, the rest are formatted
  161. # the same, except in md output format.
  162. function fmt:header {
  163. local header="$1" level="$2"
  164. case "$output" in
  165. raw)
  166. case "$level" in
  167. 1) printf "$header\n$(printf '%.0s=' {1..${#header}})\n\n" ;;
  168. 2) printf "$header\n$(printf '%.0s-' {1..${#header}})\n\n" ;;
  169. *) printf "$header:\n\n" ;;
  170. esac ;;
  171. text)
  172. case "$level" in
  173. 1|2) printf "\e[1;4m$header\e[0m\n\n" ;; # bold, underlined
  174. *) printf "\e[1m$header:\e[0m\n\n" ;; # bold
  175. esac ;;
  176. md) printf "$(printf '%.0s#' {1..${level}}) $header\n\n" ;;
  177. esac
  178. }
  179. function fmt:scope {
  180. #* Uses $scopes (A) and $hash from outer scope
  181. local scope="${1:-${scopes[$hash]}}"
  182. # Get length of longest scope for padding
  183. local max_scope=0 padding=0
  184. for hash in ${(k)scopes}; do
  185. max_scope=$(( max_scope < ${#scopes[$hash]} ? ${#scopes[$hash]} : max_scope ))
  186. done
  187. # If no scopes, exit the function
  188. if [[ $max_scope -eq 0 ]]; then
  189. return
  190. fi
  191. # Get how much padding is required for this scope
  192. padding=$(( max_scope < ${#scope} ? 0 : max_scope - ${#scope} ))
  193. padding="${(r:$padding:: :):-}"
  194. # If no scope, print padding and 3 spaces (equivalent to "[] ")
  195. if [[ -z "$scope" ]]; then
  196. printf "${padding} "
  197. return
  198. fi
  199. # Print [scope]
  200. case "$output" in
  201. raw|md) printf "[$scope]${padding} " ;;
  202. text) printf "[\e[38;5;9m$scope\e[0m]${padding} " ;; # red 9
  203. esac
  204. }
  205. # If no parameter is passed, assume it comes from `$subjects[$hash]`
  206. function fmt:subject {
  207. #* Uses $subjects (A) and $hash from outer scope
  208. local subject="${1:-${subjects[$hash]}}"
  209. # Capitalize first letter of the subject
  210. subject="${(U)subject:0:1}${subject:1}"
  211. case "$output" in
  212. raw) printf "$subject" ;;
  213. # In text mode, highlight (#<issue>) and dim text between `backticks`
  214. text) sed -E $'s|#([0-9]+)|\e[32m#\\1\e[0m|g;s|`([^`]+)`|`\e[2m\\1\e[0m`|g' <<< "$subject" ;;
  215. # In markdown mode, link to (#<issue>) issues
  216. md) sed -E 's|#([0-9]+)|[#\1](https://github.com/ohmyzsh/ohmyzsh/issues/\1)|g' <<< "$subject" ;;
  217. esac
  218. }
  219. function fmt:type {
  220. #* Uses $type from outer scope
  221. local type="${1:-${TYPES[$type]:-${(C)type}}}"
  222. [[ -z "$type" ]] && return 0
  223. case "$output" in
  224. raw|md) printf "$type: " ;;
  225. text) printf "\e[4m$type\e[24m: " ;; # underlined
  226. esac
  227. }
  228. ##* Section functions
  229. function display:version {
  230. fmt:header "$version" 2
  231. }
  232. function display:breaking {
  233. (( $#breaking != 0 )) || return 0
  234. case "$output" in
  235. raw) fmt:header "BREAKING CHANGES" 3 ;;
  236. text|md) fmt:header "⚠ BREAKING CHANGES" 3 ;;
  237. esac
  238. local hash subject
  239. for hash message in ${(kv)breaking}; do
  240. echo " - $(fmt:hash) $(fmt:subject "${message}")"
  241. done | sort
  242. echo
  243. }
  244. function display:type {
  245. local hash type="$1"
  246. local -a hashes
  247. hashes=(${(k)commits[(R)$type]})
  248. # If no commits found of type $type, go to next type
  249. (( $#hashes != 0 )) || return 0
  250. fmt:header "${TYPES[$type]}" 3
  251. for hash in $hashes; do
  252. echo " - $(fmt:hash) $(fmt:scope)$(fmt:subject)"
  253. done | sort -k3 # sort by scope
  254. echo
  255. }
  256. function display:others {
  257. local hash type
  258. # Commits made under types considered other changes
  259. local -A changes
  260. changes=(${(kv)commits[(R)${(j:|:)OTHER_TYPES}]})
  261. # If no commits found under "other" types, don't display anything
  262. (( $#changes != 0 )) || return 0
  263. fmt:header "Other changes" 3
  264. for hash type in ${(kv)changes}; do
  265. case "$type" in
  266. other) echo " - $(fmt:hash) $(fmt:scope)$(fmt:subject)" ;;
  267. *) echo " - $(fmt:hash) $(fmt:scope)$(fmt:type)$(fmt:subject)" ;;
  268. esac
  269. done | sort -k3 # sort by scope
  270. echo
  271. }
  272. ##* Release sections order
  273. # Display version header
  274. display:version
  275. # Display breaking changes first
  276. display:breaking
  277. # Display changes for commit types in the order specified
  278. for type in $MAIN_TYPES; do
  279. display:type "$type"
  280. done
  281. # Display other changes
  282. display:others
  283. }
  284. function main {
  285. # $1 = until commit, $2 = since commit
  286. local until="$1" since="$2"
  287. # $3 = output format (--text|--raw|--md)
  288. # --md: uses markdown formatting
  289. # --raw: outputs without style
  290. # --text: uses ANSI escape codes to style the output
  291. local output=${${3:-"--text"}#--*}
  292. if [[ -z "$until" ]]; then
  293. until=HEAD
  294. fi
  295. if [[ -z "$since" ]]; then
  296. # If $since is not specified:
  297. # 1) try to find the version used before updating
  298. # 2) try to find the first version tag before $until
  299. since=$(command git config --get oh-my-zsh.lastVersion 2>/dev/null) || \
  300. since=$(command git describe --abbrev=0 --tags "$until^" 2>/dev/null) || \
  301. unset since
  302. elif [[ "$since" = --all ]]; then
  303. unset since
  304. fi
  305. # Commit classification arrays
  306. local -A commits subjects scopes breaking reverts
  307. local truncate=0 read_commits=0
  308. local hash version tag
  309. # Get the first version name:
  310. # 1) try tag-like version, or
  311. # 2) try name-rev, or
  312. # 3) try branch name, or
  313. # 4) try short hash
  314. version=$(command git describe --tags $until 2>/dev/null) \
  315. || version=$(command git name-rev --no-undefined --name-only --exclude="remotes/*" $until 2>/dev/null) \
  316. || version=$(command git symbolic-ref --quiet --short $until 2>/dev/null) \
  317. || version=$(command git rev-parse --short $until 2>/dev/null)
  318. # Get commit list from $until commit until $since commit, or until root
  319. # commit if $since is unset, in short hash form.
  320. # --first-parent is used when dealing with merges: it only prints the
  321. # merge commit, not the commits of the merged branch.
  322. command git rev-list --first-parent --abbrev-commit --abbrev=7 ${since:+$since..}$until | while read hash; do
  323. # Truncate list on versions with a lot of commits
  324. if [[ -z "$since" ]] && (( ++read_commits > 35 )); then
  325. truncate=1
  326. break
  327. fi
  328. # If we find a new release (exact tag)
  329. if tag=$(command git describe --exact-match --tags $hash 2>/dev/null); then
  330. # Output previous release
  331. display-release
  332. # Reinitialize commit storage
  333. commits=()
  334. subjects=()
  335. scopes=()
  336. breaking=()
  337. reverts=()
  338. # Start work on next release
  339. version="$tag"
  340. read_commits=1
  341. fi
  342. parse-commit "$hash"
  343. done
  344. display-release
  345. if (( truncate )); then
  346. echo " ...more commits omitted"
  347. echo
  348. fi
  349. }
  350. cd "$ZSH"
  351. # Use raw output if stdout is not a tty
  352. if [[ ! -t 1 && -z "$3" ]]; then
  353. main "$1" "$2" --raw
  354. else
  355. main "$@"
  356. fi