cd /news/developer-tools/linking-decision-records-to-git-comm… · home › topics › developer-tools › article
[ARTICLE · art-146209] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=↑ positive

Linking decision records to git commits

A developer outlined a lightweight workflow for linking architecture decision records (ADRs) to the git commits that implement them, using a date-plus-slug filename convention, a Markdown template, and git commit trailers such as "Decision: adr-20260927-session-store". The approach relies on git's built-in trailer parsing and a commit-msg hook that rejects decision IDs with no matching note, letting developers trace code back to the reasoning behind it via git log --grep and git blame.

by read6 min views1 publishedOct 6, 2026

Six months after a change, git log tells you what happened and git blame tells you who. Neither tells you why you picked this approach over the two you rejected. That reasoning usually lived in a meeting, a chat thread, or your head.

Architecture decision records (ADRs) fix half of this by writing the reasoning down. The other half is linking each decision to the commits that carried it out, so you can go from code to reasoning and back. This post shows a setup that uses nothing more exotic than a notes folder, a naming convention, and git's own features.

I'll use Obsidian for the notes, but everything here works with any folder of Markdown files.

Not every commit needs one. I'd write a decision note when:

Renaming a variable doesn't qualify. Switching your session store does.

The ID is the glue, so it has to be boring and predictable. A date plus a short slug works well and sorts nicely:

decisions/adr-20260927-session-store.md

The filename is the ID. Here's a Templater template that creates the frontmatter and structure (save it as templates/decision.md):

---
id: <% tp.file.title %>
status: proposed   # proposed | accepted | superseded | rejected
decided: <% tp.date.now("YYYY-MM-DD") %>
reviewed: <% tp.date.now("YYYY-MM-DD") %>
superseded_by:
tags: [decision]
---


## Context
What problem, what constraints, what forced a decision now.

## Options considered
1. **Option A**: what it is. Pros / cons.
2. **Option B**: what it is. Pros / cons.

## Decision
We chose ... because ...

## Consequences
What gets easier, what gets harder, what we're now committed to.

## Commits
Filled in from git, see below.

Create the note with a filename like adr-20260927-session-store, apply the template, fill it in. It takes ten minutes when the decision is fresh and an hour of archaeology if you wait.

Git has a built-in convention for structured lines at the end of a commit message, called trailers (Signed-off-by: is the best-known one). Use one for decisions:

Replace JWT refresh flow with server-side sessions

Refresh-token rotation was racing across browser tabs.
Sessions now live in Redis with a 30-day sliding expiry.

Decision: adr-20260927-session-store

You can add the trailer from the command line too:

git commit -m "Move session reads to Redis" --trailer "Decision: adr-20260927-session-store"

(--trailer needs git 2.32 or newer.)

If you want a reminder, add a commit template to the repo and point git at it:



git config commit.template .gitmessage

Lines starting with # are stripped from the final message, so the hints cost nothing.

This is where the trailer pays off. No script needed:

git log --oneline --grep="Decision: adr-20260927-session-store"

git log --format='%h %s  [%(trailers:key=Decision,valueonly,separator=%x2C)]' --grep="^Decision:"

git blame -L 40,60 src/auth/session.ts
git show <hash>   # the trailer is at the bottom

The second command prints each commit with its decision ID in brackets, which makes a decent audit log on its own.

Links are only useful if they point at something real. A small commit-msg hook rejects IDs that don't match a note. (It must be commit-msg, not pre-commit: only commit-msg receives the message file as $1.)

#!/bin/sh
DECISIONS_DIR="docs/decisions"   # adjust to where your notes live

ids=$(grep -oE '^Decision: adr-[0-9]{8}-[a-z0-9-]+' "$1" | sed 's/^Decision: //')

for id in $ids; do
  if [ ! -f "$DECISIONS_DIR/$id.md" ]; then
    echo "commit-msg: no decision note found for '$id' in $DECISIONS_DIR" >&2
    exit 1
  fi
done
exit 0

Make it executable (chmod +x). It uses grep -E rather than grep -P so it also works with the BSD grep on macOS.

If your vault is a separate repo, point DECISIONS_DIR at its checkout path, or drop the hook and live with the occasional typo.

Going from commit to decision is covered by git. Going from decision to commits is covered by git log --grep too, but it's nice to see the list inside the note itself. A small script can rebuild a ## Commits section from git:

#!/bin/sh
id="$1"
note="docs/decisions/$id.md"
git log --reverse --format='- `%h` %ad %s' --date=short --grep="Decision: $id" > /tmp/commits.md
awk '/^## Commits/{print; while((getline l < "/tmp/commits.md")>0) print l; skip=1; next} !skip' "$note" > "$note.tmp" && mv "$note.tmp" "$note"

It assumes ## Commits is the last section of the note, which the template above guarantees. Run it by hand when you finish a decision; I wouldn't wire it into a post-commit hook, because a hook that edits files after every commit leaves your working tree permanently dirty.

With Dataview, a small dashboard shows what's in flight and what's gone stale:

## Proposed, not yet decided
``` dataview
LIST
FROM "decisions"
WHERE status = "proposed"
SORT decided ASC

Accepted, not reviewed in a year #

TABLE decided, reviewed
FROM "decisions"
WHERE status = "accepted" AND reviewed < date(today) - dur(1 year)
SORT reviewed ASC
A migration might take a dozen commits over several weeks. Nothing changes: every commit carries the same trailer, and `git log --grep` returns them in order. If phases matter, say so in the subject line ("Phase 2: dual-write sessions to Redis") rather than inventing more metadata.

When you decide *not* to do something, write that down too and set `status: rejected`. The next time someone proposes the same idea, there's a note with the context and the reasons, and you can judge whether those reasons still hold instead of having the whole discussion again. Same for superseded decisions: link the old note to the new one with `superseded_by` instead of deleting it.

To make it concrete, here's what a small one might look like (illustrative, not a real project):

`adr-20260310-drop-axios.md`. Context: we only use axios for simple JSON requests, and Node 18+ ships a global `fetch`. Options: keep axios, switch to `lib/http.ts` that handles retries and JSON errors. Consequence: we lose interceptors and write our own retry helper.`Decision: adr-20260310-drop-axios`
A year later, someone runs `git blame` on `lib/http.ts`, opens the commit, sees the trailer, and reads the note. Total overhead at the time: one note and one extra line per commit.

Name decision notes with a stable ID, reference it in a commit trailer, and let git do the searching. Add the hook if typos bother you and the Dataview dashboard if you use Obsidian. Everything else is optional.

If you want a ready-made starting point, [Dev Second Brain](https://subengel.gumroad.com/l/uoybkd), my Obsidian vault for developers, includes an ADR template along with Dataview dashboards.

More templates and a free Dataview starter pack are at [forge.engelailabs.com](https://forge.engelailabs.com).
── more in #developer-tools 4 stories · sorted by recency
── more on @git 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/linking-decision-rec…] indexed:0 read:6min 2026-10-06 · —