changelog.sh 13 KB

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