   (with-temp-buffer
     (save-excursion (insert commit-msg))
     (append
-     (let ((end (save-excursion
-                  (or (and (re-search-forward
-                            (format "\n\n%s "
-                                    (regexp-opt (append doom-cli-commit-ref-types
-                                                        doom-cli-commit-ref-git-types)
-                                                t))
-                            nil t)
-                           (match-beginning 1))
-                      (point-max)))))
-       `(:subject ,(buffer-substring (point-min) (line-end-position))
-         :body ,(buffer-substring (line-beginning-position 3) end)
-         :refs ,(split-string (buffer-substring end (point-max)) "\n" t)))
+     (let ((end
+            (save-excursion
+              (if (re-search-forward "\n\\(\n[a-zA-Z-]+: [^ ][^\n]+\\)+\n*\\'" nil t)
+                  (1+ (match-beginning 0))
+                (point-max)))))
+       `(:subject  ,(buffer-substring (point-min) (line-end-position))
+         :body     ,(string-trim-right (buffer-substring (line-beginning-position 3) end))
+         :trailers ,(save-match-data
+                      (cl-loop with footer = (buffer-substring end (point-max))
+                               for line in (split-string footer "\n" t)
+                               if (string-match "^\\([a-zA-Z-]+\\): \\(.+\\)$" line)
+                               collect (cons (match-string 1 line) (match-string 2 line))))))
      (save-match-data
        (when (looking-at "^\\([a-zA-Z0-9_-]+\\)\\(!?\\)\\(?:(\\([^)]+\\))\\)?: \\([^\n]+\\)")
-         `(:type ,(intern (match-string 1))
-           :bang ,(equal (match-string 2) "!")
+         `(:type    ,(intern (match-string 1))
+           :bang    ,(equal (match-string 2) "!")
            :summary ,(match-string 4)
-           :scopes ,(ignore-errors (split-string (match-string 3) ",")))))
+           :scopes  ,(ignore-errors (split-string (match-string 3) ",")))))
      (save-excursion
        (let ((bump-re "\\(\\(?:https?://.+\\|[^/ \n]+\\)/[^/ \n]+@[a-z0-9]\\{12\\}\\)")
              bumps)

        (cl-sort (delete-dups packages) #'string-lessp :key #'car)))))

(defun doom-cli--ci--lint (commits)
  (let ((errors? 0)
        (warnings? 0))
    (print! (start "Linting %d commits" (length commits)))
    (print-group!
     (dolist (commit commits)
       (let* ((plist (doom-cli--parse-commit (cdr commit)))
              (subject (plist-get plist :subject))
              warnings errors)
         (unless (string-match-p "^\\(?:\\(?:fixup\\|squash\\)!\\|FIXUP\\|WIP\\) "
                                 subject)
           (dolist (fn doom-cli-commit-rules)
             (pcase (apply fn plist)
               (`(,type . ,msg)
                (push msg (if (eq type 'error) errors warnings)))))
           (if (and (null errors) (null warnings))
               (print! (success "%s %s") (substring (car commit) 0 7) subject)
             (print! (start "%s %s") (substring (car commit) 0 7) subject))
           (print-group!
            (when errors
              (cl-incf errors?)
              (dolist (e (reverse errors))
                (print! (error "%s" e))))
            (when warnings
              (cl-incf warnings?)
              (dolist (e (reverse warnings))
                (print! (warn "%s" e)))))))))
    (when (> warnings? 0)
      (print! (warn "Warnings: %d") warnings?))
    (when (> errors? 0)
      (print! (error "Failures: %d") errors?))
    (if (not (or (> errors? 0) (> warnings? 0)))
        (print! (success "There were no issues!"))
      (terpri)
      (print! "See https://docs.doomemacs.org/latest/#/developers/conventions/git-commits for details")
      (when (> errors? 0)
        (throw 'exit 1)))))

(defun doom-cli--ci--read-commits ()
  (let (commits)
    (while (re-search-backward "^commit \\([a-z0-9]\\{40\\}\\)" nil t)
      (push (cons (match-string 1)
                  (replace-regexp-in-string
                   "^    " ""
                   (save-excursion
                     (buffer-substring-no-properties
                      (search-forward "\n\n")
                      (if (re-search-forward "\ncommit \\([a-z0-9]\\{40\\}\\)" nil t)
                          (match-beginning 0)
                        (point-max))))))
            commits))
    commits))

(defun doom-cli--ci-lint-commits (from &optional to)
  (with-temp-buffer
    (insert
     (cdr (doom-call-process
           "git" "log"
           (format "%s..%s" from (or to "HEAD")))))
    (doom-cli--ci--lint (doom-cli--ci--read-commits))))

;;
;;; Git hooks

(defvar doom-cli-commit-trailer-keys
  '(("Fix" ref hash url)
    ("Ref" ref hash url)
    ("Close" ref)
    ("Revert" ref hash)
    ("Amend" ref hash)
    ("Co-authored-by" name)
    ("Signed-off-by" name))
  "An alist of valid trailer keys and their accepted value types.

Accapted value types can be one or more of ref, hash, url, username, or name.")

(defvar doom-cli-commit-trailer-types
  '((ref      . "^\\(https?://[^ ]+\\|[^/]+/[^/]+\\)?#[0-9]+$")
    (hash     . "^\\(https?://[^ ]+\\|[^/]+/[^/]+@\\)?[a-z0-9]\\{12\\}$")
    (url      . "^https?://")
    (name     . "^[a-zA-Z0-9-_ ]+<[^@]+@[^.]+\\.[^>]+>$")
    (username . "^@[^a-zA-Z0-9_-]+$"))
  "An alist of valid trailer keys and their accepted value types.

Accapted value types can be one or more of ref, hash, url, username, or name.")

(defvar doom-cli-commit-types
  '(bump dev docs feat fix merge module nit perf refactor release revert test tweak)
  "A list of valid commit types.")

(defvar doom-cli-commit-scopes
  (list "cli"
        "ci"
        "lib"
        (fn! (scope (&key type))
          (when (and (memq type '(bump merge module release revert))
                     scope)
            (user-error "%s commits should never have a scope" type)))
        (fn! (scope _)
          (doom-glob doom-modules-dir
                     (if (string-prefix-p ":" scope)
                         (format "%s" (substring scope 1))
                       (format "*/%s" scope)))))
  "A list of valid commit scopes as strings or functions.

Functions should take two arguments: a single scope (symbol) and a commit plist
representing the current commit being checked against. See
`doom-cli-commit-core-rules' for possible values.")

(defvar doom-cli-commit-rules
  (list (fn! (&key subject)
          "If a fixup/squash commit, don't lint this commit"
          (when (string-match "^\\(\\(?:fixup\\|squash\\)!\\|FIXUP\\|WIP\\) " subject)
            (skip! (format "Found %S commit, skipping commit" (match-string 1 subject)))))

        (fn! (&key type subject)
          "Test SUBJECT length"
          (let ((len (length subject)))
            (cond ((<= len 10)
                   (fail! "Subject is too short (<10) and should be more descriptive"))
                  ((<= len 20)
                   (warn! "Subject is short (<20); are you sure it's descriptive enough?"))
                  ((memq type '(bump revert)))
                  ((> len 72)
                   (fail! "Subject is %d characters, above the 72 maximum"
                          len))
                  ((> len 50)
                   (warn! "Subject is %d characters; <=50 is ideal"
                          len)))))

        (fn! (&key type)
          "Ensure commit has valid type"
          (or (memq type doom-cli-commit-types)
              (if type
                  (fail! "Invalid commit type: %s" type)
                (fail! "Commit has no detectable type"))))

        (fn! (&key summary)
          "Ensure commit has a summary"
          (when (or (not (stringp summary))
                    (string-blank-p summary))
            (fail! "Commit has no summary")))

        (fn! (&key type summary subject)
          "Ensure summary isn't needlessly capitalized"
          (and (stringp summary)
               (string-match-p "^[A-Z][^-A-Z.]" summary)
               (fail! "%S in summary should not be capitalized"
                      (car (split-string summary " ")))))

        (fn! (&key type scopes summary)
          "Complain about scoped types that are incompatible with scopes"
          (and (memq type '(bump revert merge module release))
               scopes
               (fail! "Scopes for %s commits should go after the colon, not before"
                      type)))

        (fn! (&rest plist &key type scopes)
          "Ensure scopes are valid"
          (dolist (scope scopes)
            (condition-case e
                (or (cl-loop for rule in doom-cli-commit-scopes
                             if (or (and (stringp rule)
                                         (string= rule scope))
                                    (and (functionp rule)
                                         (funcall rule scope plist)))
                             return t)
                    (fail! "Invalid scope: %s" scope))
              (user-error (fail! "%s" (error-message-string))))))

        (fn! (&key scopes)
          "Esnure scopes are sorted correctly"
          (unless (equal scopes (sort (copy-sequence scopes) #'string-lessp))
            (fail! "Scopes are not in lexicographical order")))

        (fn! (&key type body)
          "Enforce 72 character line width for BODY"
          (catch 'result
            (with-temp-buffer
              (save-excursion (insert body))
              (while (re-search-forward "^[^\n]\\{73,\\}" nil t)
                (save-excursion
                  (or
                   ;; Long bump lines are acceptable
                   (let ((bump-re "\\(https?://.+\\|[^/]+\\)/[^/]+@[a-z0-9]\\{12\\}"))
                     (re-search-backward (format "^%s -> %s$" bump-re bump-re) nil t))
                   ;; Long URLs are acceptable
                   (re-search-backward "https?://[^ ]+\\{73,\\}" nil t)
                   ;; Lines that start with # or whitespace are comment or
                   ;; code blocks.
                   (re-search-backward "^\\(?:#\\| +\\)" nil t)
                   (throw 'result (fail! "Line(s) in commit body exceed 72 characters"))))))))

        (fn! (&key bang body type)
          "Ensure ! is accompanied by a 'BREAKING CHANGE:' in BODY"
          (if bang
              (cond ((not (string-match-p "^BREAKING CHANGE:" body))
                     (fail! "'!' present in commit type, but missing 'BREAKING CHANGE:' in body"))
                    ((not (string-match-p "^BREAKING CHANGE: .+" body))
                     (fail! "'BREAKING CHANGE:' present in commit body, but missing explanation")))
            (when (string-match-p "^BREAKING CHANGE:" body)
              (fail! "'BREAKING CHANGE:' present in body, but missing '!' after %S"
                     type))))

        (fn! (&key type body)
          "Ensure bump commits have package ref lines"
          (and (eq type 'bump)
               (let ((bump-re "\\(?:https?://.+\\|[^/]+\\)/[^/]+@\\([a-z0-9]+\\)"))
                 (not (string-match-p (concat "^" bump-re " -> " bump-re "$")
                                      body)))
               (fail! "Bump commit is missing commit hash diffs")))

        (fn! (&key body)
          "Ensure commit hashes in bump lines are 12 characters long"
          (with-temp-buffer
            (insert body)
            (let ((bump-re "\\<\\(?:https?://[^@]+\\|[^/]+\\)/[^/]+@\\([a-z0-9]+\\)")
