Conventional commits are a proposal to standardize commit messages. Writing them by hand might be tedious, so let’s improve this using Emacs, magit and Yasnippet.
This was the opportunity to play with Yasnippet, consult (and the verti&co stack)
Conventional Commits in an nutshell#
TLDR: A commit message should be structured as follows:
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
Suitable type are: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
The scope is optional and can be anything specifying the place of the change
An exclamation mark can be added before the colon to indicate a breaking change (ex feat(api)!:)
The body/Footer are optional and can be used to explain the change in more details
See the full specification for more details.
As always, there are other implementation to find in the internet (for instance here)
Automating things#
The first line of the commit message is the most standardize, and where there is standard there is automation (not necessarly worth it 1, but its always fun 2). So let’s see how we can automate this.
A nice Prompt for the commit type#
We want to have a nice completion prompt for the type, so lets use consult for that.
(defvar conv-commit-type-desc nil "Type of conventional commit")
(setq conv-commit-type-desc
'(("build"
:desc "Changes that affect the build system or external dependencies."
:icon ?
:props (:foreground "#00008B" :height 1.2))
("chore"
:desc "Updating grunt tasks."
:icon ?
:props (:foreground "gray" :height 1.2))
("ci"
:desc "Changes to CI configuration files and scripts."
:icon ?
:props (:foreground "gray" :height 1.2))
("docs"
:desc "Documentation only changes."
:icon ?
:props (:foreground "dark blue" :height 1.2))
("feat"
:desc "A new feature."
:icon ?
:props (:foreground "green" :height 1.2))
("fix"
:desc "A bug fix."
:icon ?
:props (:foreground "dark red" :height 1.2))
("perf"
:desc "A code change that improves performance."
:icon ?
:props (:foreground "dark yellow" :height 1.2))
("refactor"
:desc "A code changes that neither fixes a bug nor adds a feature."
:icon ?✀
:props (:foreground "dark green" :height 1.2))
("revert"
:desc "For commits that reverts previous commit(s)."
:icon ?⭯
:props (:foreground "dark red" :height 1.2))
("style"
:desc "Changes that do not affect the meaning of the code."
:icon ?
:props (:foreground "dark green" :height 1.2))
))
With all this nice information we can then improve the standard completing-read function using consult-read.
(defun conv-commit-type-completion-decorate (type)
"Decorate the completions candidates with icon prefix and description suffix.
TYPE is the type of conventional commit.
Return a list (candidate, icon, description)."
(let ((type-data (cdr (assoc type conv-commit-type-desc))))
(list
type
(concat
(propertize (string (plist-get type-data :icon))
'face (plist-get type-data :props))
" ")
(concat
(string-pad " " (- 10 (length type)))
(propertize (plist-get type-data :desc) 'face '(:foreground "gray" ))))))
(defun conv-commit-type-prompt ()
(interactive)
(consult--read conv-commit-type-desc
:prompt "Commit type: "
:annotate #'conv-commit-type-completion-decorate
)
)
Finding the scopes for a project#
Automatic Scope discovery#
This handy oneliner I came with will list all the scope that have already been used in a project:
git log --all --pretty=format:%s | rg '\((.*?)\)!?:' -or '$1' | sort -u
It is fast enough to be run every time, avoiding us to cache it.
An another alternative is to consider scope to be related to some hierarchy in the project, and will likely be represented as a folder (python module are a good example for instance).
fd -t d -x basename | sort -u
or in the case of some elips
fd '__init__.py' -x dirname | xargs -I _ basename _ | sort -u
Finally we could simply use a set of custom scope, defined for instance in a .dir.el
file
Now, its just a matter of plumbing it to some elisp.
(defvar conv-commit-scopes '(git-strict)
"How the scope in a project are found.")
(defvar conv-commit-scopes-finder nil
"alist of the different function to run to get the scopes.")
(defun conv-commit-strict-scope ()
"Use the list of commit type to match conventional commits with scope.
the type are matched from conv-commit-type-desc."
(let* ((run-cmd (concat "git log --all --pretty=format:%s | "
(format "rg '^(?:%s)\\((.*?)\\)!?:' -or '$1'"
(mapconcat #'car conv-commit-type-desc "|"))))
(scopes (split-string (shell-command-to-string run-cmd) "[ \n,]+" t)))
(seq-uniq scopes #'string=)))
(setq conv-commit-scopes-finder
'((git . (lambda () (split-string (shell-command-to-string "git log --all --pretty=format:%s | rg '\((.*?)\)!?:' -or '$1' | sort -u") "[ \n,]+" t)))
(dir . (lambda () (split-string (shell-command-to-string "fd -t d -x basename | sort -u") "[ \n,]+" t)))
(py . (lambda () (split-string (shell-command-to-string "fd '__init__.py' -x dirname | xargs -I _ basename _ | sort -u") "[ \n,]+" t)))
(git-strict . conv-commit-strict-scope)
))
(defun conv-commit-find-scope (scope-type)
"Return a list of scope according to SCOPE-TYPE.
If SCOPE-TYPE is a function, it is called.
If SCOPE-TYPE is a list, it is returned as is.
If SCOPE-TYPE is a symbol, it is looked up in `conv-commit-scope-shell' and the associated command is called."
(pcase scope-type
((pred functionp) (funcall scope-type))
((pred listp) scopes)
((and (let cmd (alist-get scope-type conv-commit-scopes-finder))
(guard (functionp cmd)))
(funcall cmd))
))
(defun conv-commit-get-scope ()
"Get list of possible scopes. "
(list "" (seq-uniq (sort (flatten-tree (mapcar #'conv-commit-find-scope conv-commit-scopes))
#'string<)))
)
Great ! we have now a nice input for the type and some hints for the scope. Now we need to put it all together.
(defun conv-commit-scope-prompt ()
"Prompt for a conventional commit scope."
(interactive)
(let ((scope (completing-read "Scope: " (conv-commit-get-scope))))
(if (string= scope "")
""
(format "(%s)" scope))))
(defun conv-commit-prompt ()
"Prompt for a conventional commit. and fill the buffer with the result."
(interactive)
(insert (conv-commit-type-prompt))
(let ((scope (completing-read "Scope: " (conv-commit-get-scope))))
(insert (if (string= scope "") "" (format "(%s)" scope))))
(insert (if (y-or-n-p "Breaking change? ") "!" ""))
(insert ": ")
)
Now, there is a tricky part. One could consider this done, and simply had our prompt to the git commit mode hook. But this would prompt before showing the commit message buffer (you can try this at home).
;; don't do this !
(add-hook 'git-commit-mode-hook #'conv-commit-prompt)
Instead we are going to check eagearly if the commit message buffer is displayed, and if the commit mesage is empty (on its first line). It is a bit hacky, and at an unbearingly slow 300ms it still feals snappy to me (and give enough time to emacs to fully initialize the diff buffer).
(add-hook 'git-commit-setup-hook #'(lambda ()
(run-with-timer 0.3 nil #'(lambda () (when (eq (point-at-eol) (point-at-bol)) (conv-commit-prompt)))))
Conclusion#
(copy-file "code/conv-commit.el" "~/.doom.d/conv-commit.el" 1 t)
Relevant xkcd: https://xkcd.com/1205/ ↩︎
Relevant xkcd: https://xkcd.com/1319/ ↩︎