# Linking decision records to git commits

> Source: <https://dev.to/productivityforge/linking-decision-records-to-git-commits-1f3a>
> Published: 2026-10-06 17:09:21+00:00

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]
---

# <% tp.file.title %>

## 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:

```
# .gitmessage in the repo root
# Subject line (~50 chars)

# Why this change?

# Decision: adr-YYYYMMDD-slug   (delete if not applicable)
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:

```
# Every commit that implemented a given decision
git log --oneline --grep="Decision: adr-20260927-session-store"

# All commits that reference any decision, with the ID shown
git log --format='%h %s  [%(trailers:key=Decision,valueonly,separator=%x2C)]' --grep="^Decision:"

# Which decision explains this line? blame first, then read the commit
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`.)

``` bash
#!/bin/sh
# .git/hooks/commit-msg  (or .husky/commit-msg)
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:

``` bash
#!/bin/sh
# scripts/decision-commits.sh adr-20260927-session-store
id="$1"
note="docs/decisions/$id.md"
git log --reverse --format='- `%h` %ad %s' --date=short --grep="Decision: $id" > /tmp/commits.md
# Replace everything after the "## Commits" heading with the fresh list
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
``` dataview
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).
