Building a Conventional Commits Generator: Stop Writing Bad Git Messages#

We’ve all been there. You run git log on a project and see messages like “fix”, “update”, or the infamous “asdf”. Six months later, you have no idea what any commit actually did.

Conventional Commits solves this with a standardized format that’s both machine-readable and human-friendly. Let’s build a commit message generator from scratch, and dig into the details that actually matter.

The Format#

The specification is refreshingly simple:

<type>(<scope>): <description>

<body>

<footer>

Here’s what it looks like in practice:

feat(auth): add OAuth2 login support

Implement Google and GitHub OAuth2 authentication flow.
Includes token refresh and session management.

Closes #123, #456

The spec defines 11 types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, and revert. Each one has a clear meaning, so a changelog generator or release script can process them automatically.

The 72-Character Rule#

Why 72 characters for the description line? It’s not arbitrary. Git’s default display width is 80 columns, and the type(scope): prefix typically eats up 8-12 characters. That leaves around 70 for the actual description. Beyond 72, git log --oneline wraps and becomes unreadable.

This convention comes from the Linux kernel community — decades of Git history baked into a simple rule. Here’s the validation:

const descriptionError = useMemo(() => {
  if (description && description.length > 72) {
    return 'Description should be under 72 characters'
  }
  return ''
}, [description])

The Core: Building the Message#

The heart of a commit generator is string assembly. The logic is straightforward — around 25 lines:

const commitMessage = useMemo(() => {
  let msg = ''

  // Type is required
  msg += commitType

  // Scope is optional
  if (scope.trim()) {
    msg += `(${scope.trim()})`
  }

  // Breaking change marker
  if (breakingChange) {
    msg += '!'
  }

  // Description is required
  if (description.trim()) {
    msg += `: ${description.trim()}`
  } else {
    msg += ': '
  }

  // Body and footer are separated by blank lines
  if (body.trim()) {
    msg += `\n\n${body.trim()}`
  }

  const footerParts: string[] = []
  if (breakingChange) {
    footerParts.push('BREAKING CHANGE: ')
  }
  if (footer.trim()) {
    footerParts.push(footer.trim())
  }
  if (footerParts.length > 0) {
    msg += `\n\n${footerParts.join('\n')}`
  }

  return msg
}, [commitType, scope, description, body, footer, breakingChange])

Three details worth noting:

  1. ! placement: The breaking change marker goes between the scope parenthesis and the colon — feat(api)!: remove deprecated v1 endpoints. This is specified by the convention.
  2. BREAKING CHANGE in footer: The keyword must be uppercase, followed by a colon and space. It goes on its own line before any other footer content.
  3. Blank line separators: The header, body, and footer sections are separated by two newlines — this is how Git parses commit messages internally.

Why useMemo Matters for Live Preview#

The generator shows a live preview that updates as the user types. Without optimization, every keystroke triggers a string re-assembly and a React re-render. useMemo solves this elegantly:

const commitMessage = useMemo(() => { ... }, [dependencies])

It only recalculates when a dependency actually changes. If the user edits the body, the scope and description calculations are preserved. This pattern is cleaner than useEffect + setState because it eliminates an extra render cycle.

Choosing a Scope Convention#

Scope describes which module the change affects. Common choices include directory names (auth, api, ui) or microservice names. There’s no right answer — consistency matters more than the choice itself.

One pattern I’ve seen work well: use npm package names. In a monorepo, commits like feat(@scope/core): add retry logic and feat(@scope/hooks): expose useQuery map directly to changelog sections.

Footers are primarily used for issue tracking and attribution:

Closes #123
Refs: #456
Co-authored-by: Name <email>
Reviewed-by: Name <email>

GitHub and GitLab recognize these keywords and can auto-close issues when a PR merges. This is why the generator’s footer input suggests Closes #123, Refs: #456 as placeholder text.

Wrapping Up#

The real value of Conventional Commits isn’t aesthetics — it’s automation. Structured commit messages feed directly into changelog generators, semantic version calculators, and release pipelines. Angular, ESLint, and most major open-source projects use this standard. It’s well worth adopting.

Try the online tool: Git Commit Generator


Related tools: Changelog Generator | README Generator

Published: 2026-05-02 06:09 GMT+8