{"slug": "linking-decision-records-to-git-commits", "title": "Linking decision records to git commits", "summary": "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.", "body_md": "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.\n\nArchitecture 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.\n\nI'll use Obsidian for the notes, but everything here works with any folder of Markdown files.\n\nNot every commit needs one. I'd write a decision note when:\n\nRenaming a variable doesn't qualify. Switching your session store does.\n\nThe ID is the glue, so it has to be boring and predictable. A date plus a short slug works well and sorts nicely:\n\n```\ndecisions/adr-20260927-session-store.md\n```\n\nThe filename *is* the ID. Here's a Templater template that creates the frontmatter and structure (save it as `templates/decision.md`):\n\n```\n---\nid: <% tp.file.title %>\nstatus: proposed   # proposed | accepted | superseded | rejected\ndecided: <% tp.date.now(\"YYYY-MM-DD\") %>\nreviewed: <% tp.date.now(\"YYYY-MM-DD\") %>\nsuperseded_by:\ntags: [decision]\n---\n\n# <% tp.file.title %>\n\n## Context\nWhat problem, what constraints, what forced a decision now.\n\n## Options considered\n1. **Option A**: what it is. Pros / cons.\n2. **Option B**: what it is. Pros / cons.\n\n## Decision\nWe chose ... because ...\n\n## Consequences\nWhat gets easier, what gets harder, what we're now committed to.\n\n## Commits\nFilled in from git, see below.\n```\n\nCreate 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.\n\nGit 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:\n\n```\nReplace JWT refresh flow with server-side sessions\n\nRefresh-token rotation was racing across browser tabs.\nSessions now live in Redis with a 30-day sliding expiry.\n\nDecision: adr-20260927-session-store\n```\n\nYou can add the trailer from the command line too:\n\n```\ngit commit -m \"Move session reads to Redis\" --trailer \"Decision: adr-20260927-session-store\"\n```\n\n(`--trailer` needs git 2.32 or newer.)\n\nIf you want a reminder, add a commit template to the repo and point git at it:\n\n```\n# .gitmessage in the repo root\n# Subject line (~50 chars)\n\n# Why this change?\n\n# Decision: adr-YYYYMMDD-slug   (delete if not applicable)\ngit config commit.template .gitmessage\n```\n\nLines starting with `#` are stripped from the final message, so the hints cost nothing.\n\nThis is where the trailer pays off. No script needed:\n\n```\n# Every commit that implemented a given decision\ngit log --oneline --grep=\"Decision: adr-20260927-session-store\"\n\n# All commits that reference any decision, with the ID shown\ngit log --format='%h %s  [%(trailers:key=Decision,valueonly,separator=%x2C)]' --grep=\"^Decision:\"\n\n# Which decision explains this line? blame first, then read the commit\ngit blame -L 40,60 src/auth/session.ts\ngit show <hash>   # the trailer is at the bottom\n```\n\nThe second command prints each commit with its decision ID in brackets, which makes a decent audit log on its own.\n\nLinks 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`.)\n\n``` bash\n#!/bin/sh\n# .git/hooks/commit-msg  (or .husky/commit-msg)\nDECISIONS_DIR=\"docs/decisions\"   # adjust to where your notes live\n\nids=$(grep -oE '^Decision: adr-[0-9]{8}-[a-z0-9-]+' \"$1\" | sed 's/^Decision: //')\n\nfor id in $ids; do\n  if [ ! -f \"$DECISIONS_DIR/$id.md\" ]; then\n    echo \"commit-msg: no decision note found for '$id' in $DECISIONS_DIR\" >&2\n    exit 1\n  fi\ndone\nexit 0\n```\n\nMake it executable (`chmod +x`). It uses `grep -E` rather than `grep -P` so it also works with the BSD grep on macOS.\n\nIf your vault is a separate repo, point `DECISIONS_DIR` at its checkout path, or drop the hook and live with the occasional typo.\n\nGoing 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:\n\n``` bash\n#!/bin/sh\n# scripts/decision-commits.sh adr-20260927-session-store\nid=\"$1\"\nnote=\"docs/decisions/$id.md\"\ngit log --reverse --format='- `%h` %ad %s' --date=short --grep=\"Decision: $id\" > /tmp/commits.md\n# Replace everything after the \"## Commits\" heading with the fresh list\nawk '/^## Commits/{print; while((getline l < \"/tmp/commits.md\")>0) print l; skip=1; next} !skip' \"$note\" > \"$note.tmp\" && mv \"$note.tmp\" \"$note\"\n```\n\nIt 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.\n\nWith Dataview, a small dashboard shows what's in flight and what's gone stale:\n\n```\n## Proposed, not yet decided\n``` dataview\nLIST\nFROM \"decisions\"\nWHERE status = \"proposed\"\nSORT decided ASC\n```\n\n## Accepted, not reviewed in a year\n``` dataview\nTABLE decided, reviewed\nFROM \"decisions\"\nWHERE status = \"accepted\" AND reviewed < date(today) - dur(1 year)\nSORT reviewed ASC\nA 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.\n\nWhen 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.\n\nTo make it concrete, here's what a small one might look like (illustrative, not a real project):\n\n`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`\nA 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.\n\nName 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.\n\nIf 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.\n\nMore templates and a free Dataview starter pack are at [forge.engelailabs.com](https://forge.engelailabs.com).", "url": "https://wpnews.pro/news/linking-decision-records-to-git-commits", "canonical_source": "https://dev.to/productivityforge/linking-decision-records-to-git-commits-1f3a", "published_at": "2026-10-06 17:09:21+00:00", "updated_at": "2026-10-06 17:19:14.965522+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Git", "Obsidian"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/linking-decision-records-to-git-commits", "markdown": "https://wpnews.pro/news/linking-decision-records-to-git-commits.md", "text": "https://wpnews.pro/news/linking-decision-records-to-git-commits.txt", "jsonld": "https://wpnews.pro/news/linking-decision-records-to-git-commits.jsonld"}}