changelog.sh 13 KB

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