It is generally seen as best practice for a project to keep a changelog as this gives people who interact with the project a easy why to see what has changed between releases. The problem is there is no real one size fits all solution to how changelogs should be maintained, some projects may have a text file that developers manually edit whenever something is changed with the project while others may parse this out of of their version control tool and auto generate one. In this blog post I will talk about a way to do the latter using git-notes.

Git-notes is a powerful tool that allows for adding additional information to a commit that doesn’t show up in commit message itself. What is even better is that git-notes doesn’t modify the object it is attached to, instead they are stored as their own reference in Git and a repository can have as many note references as is needed without causing any conflicts. By default when git note add <commit hash> is called it creates a new object under refs/notes/commits that points to the given commit hash, or if no commit hash is given then it points to the most recent commit. One big downside to how git-notes works is that it can be pretty hard to tell if a project is making use of them as by default notes don’t show up in a standard git log output and when inspecting a commit that has one or more notes attached to it the only clue is that you will see a unindented line saying Notes (<refname>): followed by the notes. At first I wrote off the idea of using git-notes due to this behavior, but after seeing that Google built a whole code review tool for Git that is backed by git-notes it started to get me thinking what else they can be used for. Enter the project changelog.

Setup

Knowing that by default git-notes stores everything in refs/notes/commits, this implies that git-notes could support many references, and in fact it does looking at the documentation for git-notes there is a --ref argument that overrides the default note location. This means that changelog entries can be stored in refs/notes/changelog and still be separate from the commit message and leave the default note namespace clear for other things. To record a new changelog entry the user can use git notes --ref=changelog add <commit hash>. While the syntax is not perfect due to how much typing this requires and leaves space for user error due to typos, this short coming can be addressed by creating a new alias in Git for it.

git config --global alias.changelog 'notes --ref=changelog add'

This creates a new changelog alias in Git stored in the global git configuration file $HOME/.gitconfig which now allows for changelog entries to be recorded with git changelog <commit hash>.

Adding a changelog entry

Thanks to the changelog alias that was created earlier, adding a new changelog entry is as easy as calling git changelog which will then open a text editor with a pretty familiar sight.

Writing a changelog entry using Git

Writing a changelog entry using Git

Syncing changelog entries between remotes and systems

Another short coming of the way git-notes works is that by default they are not included when a push, pull, or fetch operation is ran. This can be addressed by editing the projects .git/config file and add the following lines to the remote section(s):

fetch = +refs/notes/changelog:refs/notes/changelog
push = +refs/notes/changelog:refs/notes/changelog

This way when a push, pull, or fetch operation is done in Git the changelog notes will also be included.

Writing out the CHANGELOG file

The final thing needed to pull this all together is a way to take this information and write it out to in an easy way to read. Because the notes themselves are just objects in Git that means we can easily interact with them like we would anything else. It’s also important that the notes show up with the correct release of the project, project releases of course being tracked using git-tag also makes this easy. The way I approched this is with a simple bash script that walks the git commit history pulling the tags and changelog notes and writes out a markdown file following the format documented by Keep a Changelog.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
#!/usr/bin/env bash

get_tag() {
    local rev="$1"
    local lastTag="$2"

    local tag=''
    IFS='-' read -ra tag <<< "$(git describe --tags "${rev}")"

    if [[ -z "${tag[1]}" ]]; then
        echo -n "${tag[0]}"
    else
        echo -n "${lastTag}"
    fi
}

get_tag_date() {
    local tag="$1"

    local date=''
    date="$(git log -1 --pretty=tformat:%ad --date=short "${tag}")"
    echo -n "${date}"
}

check_git_tag() {
    local rev="$1"

    if git describe --tags "${rev}" &> /dev/null; then
        local tag=''
        tag="$(get_tag "${rev}" "${lastTag}")"

        if [[ "${lastTag}" != "${tag}" ]]; then
            local date=''
            date="$(get_tag_date "${tag}")"
            echo '' # Add a blank line
            echo "## [${tag}] - ${date}"
            lastTag="${tag}"
        fi
    fi
}

get_changelog_notes() {
    local rev="$1"

    if git notes --ref=changelog list "${rev}" &> /dev/null; then
        local line=''
        line="$(git notes --ref=changelog show "${rev}")"
        echo "- ${line}"
    fi
}

get_changelog_notes_by_section() {
    local rev="$1"
    local section="$2"

    if git notes --ref=changelog list "${rev}" &> /dev/null; then
        local line=''
        line="$(git notes --ref=changelog show "${rev}")"
        if [[ "${line}" =~ ^${section} ]]; then
            echo "- ${line}"
        fi
    fi
}

generate_header() {
    echo "# Changelog"
    echo 'All notable changes to this project will be documented in this file.'
    echo "" # Blank line intentional
    echo 'The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)'
    printf '\n## %s\n' "[Unreleased]"
}

main() {
    local lastTag=''
    generate_header

    sections=('\[Added\]' '\[Changed\]' '\[Deprecated\]' '\[Removed\]' '\[Fixed\]' '\[Security\]')
    for rev in $(git rev-list HEAD); do
        for section in "${sections[@]}"; do
            check_git_tag "${rev}"
            get_changelog_notes_by_section "${rev}" "${section}"
        done
    done

    exit 0
}

main "$@"

This will produce output that looks something like the following:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# Changelog
All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)

## [Unreleased]

## [v0.3.0] - 2021-08-04
- [Changed] Improve error and logging handling
- [Changed] Clean up the output from the list command
- [Added] Add ability to remove a host entry from the DB
- [Added] Added TUI interface for editing and adding host entries
- [Added] Added a command to write out a SSH config file
- [Added] Add a way to import an existing SSH config file into the DB
- [Changed] Switch from github.com/peterbourgon/diskv to bbolt for managing internal DB store.

## [v0.2.0] - 2015-02-08
...

## [v0.1] - 2014-03-30
...

There we have it, our changelog stored in a distributed and easy to generate way!