Skip to content

Generate from git history

GenerateFromRepo reads a repository's tags and commits and returns a changelog as markdown. It opens the repo with go-git (PlainOpenWithOptions with DetectDotGit), so the path can be the repo root or any subdirectory of it — no git binary is invoked.

func GenerateFromRepo(repoPath string, opts ...GenerateOption) (string, error)

Releases come out newest-first, entries grouped by category within each release. Nothing is written to disk; the string is yours to do something with.

Bound how much history you get

With no options, the entire history is emitted — every release back to the first commit. There is no default window, so on a repository with two hundred tags you get two hundred sections. Bound it explicitly:

// Only releases after a given tag (exclusive).
md, err := changelog.GenerateFromRepo(".", changelog.WithSinceTag("v1.2.0"))

// Keep only the newest N sections.
md, err = changelog.GenerateFromRepo(".", changelog.WithMaxReleases(10))

The two combine: WithSinceTag sets the lower bound and WithMaxReleases caps the count within it. Watch one detail — Unreleased counts as one of the N, so on a repository with untagged commits WithMaxReleases(2) gives you Unreleased and one tagged release.

WithSinceTag is validated against the repository's tags. A value matching no semver tag and naming no existing tag is rejected with ErrSinceTagNotFound rather than silently disabling the filter and emitting everything, so a typo surfaces immediately:

md, err := changelog.GenerateFromRepo(".", changelog.WithSinceTag("v.1.2.0"))
if errors.Is(err, changelog.ErrSinceTagNotFound) {
    // the tag is wrong, not the repository
}

Full behaviour of all three options, including what n <= 0 does, is in generation options.

Include commits that predate the convention

WithIncludeAll does not widen the range — it widens what counts as a commit worth listing. Without it, a subject that is not a valid Conventional Commit is dropped silently; with it, that subject appears verbatim under Other:

md, err := changelog.GenerateFromRepo(".", changelog.WithIncludeAll())
### Other

* quick fix before demo
* security: patch the parser

Reach for it on a repository that adopted the convention partway through. It does not bring back test and ci commits, which are dropped by type rather than by parse failure.

Generate for a repository you are not standing in

The path is a repository path, not a working directory, and DetectDotGit means any subdirectory works too:

md, err := changelog.GenerateFromRepo("/srv/checkouts/myproject/internal/api")

If no .git exists at the path or above it, you get opening git repository: repository does not exist.

Write it to CHANGELOG.md

There is no option for this — the module returns a string and stops:

md, err := changelog.GenerateFromRepo(".", changelog.WithMaxReleases(20))
if err != nil {
    return err
}

if err := os.WriteFile("CHANGELOG.md", []byte(md), 0o644); err != nil {
    return err
}

Regeneration rewrites the whole document from history. There is no incremental mode and nothing merges with an existing file, so writing the string is a full overwrite by design.

Generate inside CI

Two things catch people out in a pipeline:

  • Fetch the full history. A shallow clone fails with iterating commits: object not found, because the walk needs commits the clone never fetched. In GitLab CI set GIT_DEPTH: "0".
  • Fetch tags. Without tags there are no releases to attribute commits to, and the entire history comes back as one Unreleased section.

What each commit becomes

Every commit subject is parsed as a Conventional Commit. The type selects a category (feat → feature, fix → fix, perf → performance, others → other), test and ci are dropped, and a breaking marker or footer overrides the type. Scope and description are carried onto the entry.

Only eleven commit types are recognised at all — security: and deps: are not among them. The full list, and what happens to everything else, is in commit classification.

When the output is not what you expected

Symptom Cause
Empty string, no error No commits, or every commit was dropped by classification
Everything under one Unreleased heading No semver tags — non-semver tags such as release-4 are invisible
A commit is missing Its type is test or ci, it is a merge commit, or its subject did not parse
A commit is in the wrong release Attribution is by ancestry, not date — see how commits are attributed to releases
A version heading with nothing under it Two tags on the same commit; the lower one claimed the entries
A version missing entirely Every commit in it was dropped, so the section was omitted
iterating commits: object not found Shallow clone

Limitations

GenerateFromRepo cannot filter by path, scope or author, cannot emit commit hashes, dates, authors or links, cannot change the output format, and reads nothing from the commit body except the breaking-change footer. The reasoning is in what changelog does not do.