changelog.sh 13 KB

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