Journey to Automating Markdown Frontmatter with Git Hooks
Beginning: The Problem Starts - Limitations of Pre-commit Hook
Managing frontmatter for each post manually while running a Markdown blog was tedious. Especially having to manually input metadata like commit messages, authors, and dates every single time.
Initially, I tried to solve this using a pre-commit hook.
# .husky/pre-commit (initial version)
# Update frontmatter for staged markdown files
Problems with Pre-commit Approach
But I soon discovered serious issues:
- Staging changes persist: After updating frontmatter, new changes always remained in Git staging
- Additional commit required: Another commit was needed to reflect the updated frontmatter
- No commit message access: Most critically, at the pre-commit stage, the commit message isn't finalized yet, so it couldn't be included in the frontmatter
// This is impossible in pre-commit
const commitMessage = "???"; // Doesn't exist yet
Development: Transition to Post-commit Hook
Realizing the limitations of pre-commit, I switched direction to a post-commit hook.
# .husky/post-commit
# Update frontmatter after commit + git commit --amend
Post-commit + Amend Approach
This approach worked as follows:
- User commits:
"✨ Add new feature"
- Post-commit hook executes
- Extract message from commit history and update frontmatter
- Include changes in existing commit with
git commit --amend --no-edit --no-verify
// scripts/update-frontmatter-postcommit.js
const changedFiles = execSync('git diff --name-only HEAD HEAD~1')
.trim().split('\n').filter(file => file.endsWith('.md'));
// ... update frontmatter ...
// Add changes to existing commit
execSync('git commit --amend --no-edit --no-verify');
Emergence of New Complexity
While the post-commit approach solved the commit message access problem, it introduced new issues:
- Infinite loop risk:
git commit --amend
triggers another post-commit hook - Complex prevention logic needed:
# Infinite loop prevention code if [ "$FRONTMATTER_UPDATE_RUNNING" = "1" ]; then exit 0 fi export FRONTMATTER_UPDATE_RUNNING=1
- Git history tampering: SHA changes could cause conflicts during collaboration
- --no-verify limitations: Discovered that post-commit hooks can't be skipped with
--no-verify
Turning Point: Searching for a Fundamental Solution
Looking at the complicated code, I thought there must be a better way. After deeper investigation into Git hooks, I discovered the perfect solution: prepare-commit-msg hook.
Discovering prepare-commit-msg Hook
Through web research, I confirmed the exact order of Git commit workflow:
- Stage files (
git add
) - Execute pre-commit hook
- Execute prepare-commit-msg hook ← This is the key!
- Execute commit-msg hook
- Create actual commit
- Execute post-commit hook
Perfect Timing of Prepare-commit-msg
I discovered the characteristics of this hook:
- ✅ Commit message is already finalized
- ✅ Before the commit is created
- ✅ Can modify files and re-stage
- ✅ Automatically included in the same commit
# prepare-commit-msg hook parameters
# $1 = commit message file path
# $2 = commit source (message, template, merge, etc.)
CURRENT_MSG=$(cat "$1") # Can read commit message!
Resolution: Implementing the Perfect Solution
Final Implementation
I completely reimplemented using the prepare-commit-msg approach:
# .husky/prepare-commit-msg
#!/bin/sh
COMMIT_MSG_FILE="$1"
COMMIT_SOURCE="$2"
# Only run for regular commits
if [ "$COMMIT_SOURCE" = "message" ] || [ -z "$COMMIT_SOURCE" ]; then
CURRENT_MSG=$(cat "$COMMIT_MSG_FILE")
# Find staged markdown files
STAGED_MD_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.md$')
if [ -n "$STAGED_MD_FILES" ]; then
export COMMIT_MESSAGE="$CURRENT_MSG"
if pnpm exec node scripts/update-frontmatter-prepare.js; then
echo "✅ Frontmatter updated and re-staged successfully"
fi
fi
fi
// scripts/update-frontmatter-prepare.js
const commitMessage = process.env.COMMIT_MESSAGE;
const stagedFiles = execSync('git diff --cached --name-only --diff-filter=ACM')
.trim().split('\n').filter(file => file && file.endsWith('.md'));
// Update frontmatter
const updatedData = {
title: title,
description: description,
authors: uniqueAuthors,
dates: dates,
messages: [commitMessage, ...existingMessages], // Include current commit message!
created: createdDate,
modified: modifiedDate
};
// Update file and auto re-stage
writeFileSync(filePath, updatedContent);
execSync(`git add "${file}"`); // Automatically included in same commit
Revolutionary Results
Advantages of the final implementation:
- Perfect timing: After commit message finalization, before commit creation
- Automatic inclusion: Re-staged changes naturally included in the same commit
- Complexity removed: No need for infinite loop prevention logic
- History integrity: No SHA changes, no amend needed
- Collaboration safe: Leverages Git's legitimate workflow
Real-world Example
$ git commit -m "✨ Add new feature"
🔍 Running pre-commit checks...
✅ All pre-commit checks passed!
📝 Running prepare-commit-msg hook...
📄 Found staged markdown files: new-feature.md
✅ new-feature.md frontmatter updated
✅ Updated frontmatter has been staged.
📝 Validating commit message...
✅ Commit message validation passed
[main abc1234] ✨ Add new feature
2 files changed, 50 insertions(+)
Resulting markdown:
---
title: New Feature
description: The innovative feature we just added
authors:
- XIYO
messages:
- '✨ Add new feature' # Automatically included!
created: '2025-07-20T09:40:48.024Z'
modified: '2025-07-20T09:40:48.024Z'
lastModified: 2025-07-27T21:08:36+09:00
published: 2025-07-22T02:44:08+09:00
---
# New Feature
The innovative feature we just added...
Conclusion: The Journey of Finding the Right Tool
Lessons learned from this project:
- Understanding the root of the problem: Not just finding a working solution, but understanding why the problem occurs
- Deep exploration of tools: Understanding the various types of Git hooks and their characteristics
- Complexity is a signal: If code becomes complex, there's likely a better way
- Power of the legitimate approach: Utilizing workflows aligned with Git's design intent
This journey from Pre-commit → Post-commit → Prepare-commit-msg was more than just a technical solution—it was a valuable experience that made me think about the essence of problem-solving.
The frontmatter of this post was also automatically generated by the prepare-commit-msg hook! 🎉