diff --git a/cmd/uplift/changelog.go b/cmd/uplift/changelog.go index 766262aa..433a78b5 100644 --- a/cmd/uplift/changelog.go +++ b/cmd/uplift/changelog.go @@ -82,6 +82,9 @@ uplift changelog --no-stage # Generate a changelog with multiline commit messages uplift changelog --multiline +# Generate a changelog trimming any lines preceding the conventional commit type +uplift changelog --trim-header + # Generate a changelog with prerelease tags being skipped uplift changelog --skip-prerelease` ) @@ -94,6 +97,7 @@ type changelogOptions struct { Sort string Multiline bool SkipPrerelease bool + TrimHeader bool *globalOptions } @@ -136,6 +140,7 @@ func newChangelogCmd(gopts *globalOptions, out io.Writer) *changelogCommand { f.StringVar(&chglogCmd.Opts.Sort, "sort", "", "the sort order of commits within each changelog entry") f.BoolVar(&chglogCmd.Opts.Multiline, "multiline", false, "include multiline commit messages within changelog (skips truncation)") f.BoolVar(&chglogCmd.Opts.SkipPrerelease, "skip-prerelease", false, "skips the creation of a changelog entry for a prerelease") + f.BoolVar(&chglogCmd.Opts.TrimHeader, "trim-header", false, "strip any lines preceding the conventional commit type in the commit message") chglogCmd.Cmd = cmd return chglogCmd @@ -204,6 +209,11 @@ func setupChangelogContext(opts changelogOptions, out io.Writer) (*context.Conte ctx.Changelog.SkipPrerelease = ctx.Config.Changelog.SkipPrerelease } + ctx.Changelog.TrimHeader = opts.TrimHeader + if !ctx.Changelog.TrimHeader && ctx.Config.Changelog != nil { + ctx.Changelog.TrimHeader = ctx.Config.Changelog.TrimHeader + } + // Sort order provided as a command-line flag takes precedence ctx.Changelog.Sort = opts.Sort if ctx.Changelog.Sort == "" && cfg.Changelog != nil { diff --git a/cmd/uplift/changelog_test.go b/cmd/uplift/changelog_test.go index 46db04de..6ede6f61 100644 --- a/cmd/uplift/changelog_test.go +++ b/cmd/uplift/changelog_test.go @@ -332,3 +332,25 @@ fix: 1 assert.NotContains(t, cl, "## 0.1.0-pre.2") assert.NotContains(t, cl, "## 0.1.0-pre.1") } + +func TestChangelog_TrimHeader(t *testing.T) { + log := `>(tag: 0.1.0) feat: this is a commit +>this line that should be ignored +this line that should also be ignored +feat: second commit` + gittest.InitRepository(t, gittest.WithLog(log)) + + chglogCmd := newChangelogCmd(noChangesPushed(), os.Stdout) + chglogCmd.Cmd.SetArgs([]string{"--trim-header"}) + + err := chglogCmd.Cmd.Execute() + require.NoError(t, err) + + assert.True(t, changelogExists(t)) + + cl := readChangelog(t) + assert.Contains(t, cl, `feat: this is a commit`) + assert.Contains(t, cl, "feat: second commit") + assert.NotContains(t, cl, "this line that should be ignored") + assert.NotContains(t, cl, "this line that should also be ignored") +} diff --git a/cmd/uplift/release.go b/cmd/uplift/release.go index 494ccb39..1748afd2 100644 --- a/cmd/uplift/release.go +++ b/cmd/uplift/release.go @@ -94,6 +94,7 @@ type releaseOptions struct { Sort string Multiline bool SkipPrerelease bool + TrimHeader bool *globalOptions } @@ -137,6 +138,7 @@ func newReleaseCmd(gopts *globalOptions, out io.Writer) *releaseCommand { f.StringVar(&relCmd.Opts.Sort, "sort", "", "the sort order of commits within each changelog entry") f.BoolVar(&relCmd.Opts.Multiline, "multiline", false, "include multiline commit messages within changelog (skips truncation)") f.BoolVar(&relCmd.Opts.SkipPrerelease, "skip-changelog-prerelease", false, "skips the creation of a changelog entry for a prerelease") + f.BoolVar(&relCmd.Opts.TrimHeader, "trim-header", false, "strip any lines preceding the conventional commit type in the commit message") relCmd.Cmd = cmd return relCmd @@ -209,6 +211,10 @@ func setupReleaseContext(opts releaseOptions, out io.Writer) (*context.Context, if !ctx.Changelog.SkipPrerelease && ctx.Config.Changelog != nil { ctx.Changelog.SkipPrerelease = ctx.Config.Changelog.SkipPrerelease } + ctx.Changelog.TrimHeader = opts.TrimHeader + if !ctx.Changelog.TrimHeader && ctx.Config.Changelog != nil { + ctx.Changelog.TrimHeader = ctx.Config.Changelog.TrimHeader + } // By default ensure the ci(uplift): commits are excluded also ctx.Changelog.Exclude = append(ctx.Changelog.Exclude, "ci(uplift):") diff --git a/cmd/uplift/release_test.go b/cmd/uplift/release_test.go index b73c0db5..51b23e05 100644 --- a/cmd/uplift/release_test.go +++ b/cmd/uplift/release_test.go @@ -310,3 +310,25 @@ func TestRelease_SkipChangelogPrerelease(t *testing.T) { assert.NotContains(t, cl, "## 0.1.0-pre.2") assert.NotContains(t, cl, "## 0.1.0-pre.1") } + +func TestRelease_TrimHeader(t *testing.T) { + log := `> feat: this is a commit +>this line that should be ignored +this line that should also be ignored +feat: second commit` + gittest.InitRepository(t, gittest.WithLog(log)) + + relCmd := newReleaseCmd(noChangesPushed(), os.Stdout) + relCmd.Cmd.SetArgs([]string{"--trim-header"}) + + err := relCmd.Cmd.Execute() + require.NoError(t, err) + + assert.True(t, changelogExists(t)) + + cl := readChangelog(t) + assert.Contains(t, cl, `feat: this is a commit`) + assert.Contains(t, cl, "feat: second commit") + assert.NotContains(t, cl, "this line that should be ignored") + assert.NotContains(t, cl, "this line that should also be ignored") +} diff --git a/docs/changelog.md b/docs/changelog.md index 03697521..5842d177 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -61,3 +61,11 @@ Prevent any prerelease from being included in your changelog. Upon your next rel ```sh uplift changelog --skip-prerelease ``` + +## Trim Header + +Trims any lines preceding the conventional commit type in the commit message + +```sh +uplift changelog --skip-prerelease +``` \ No newline at end of file diff --git a/docs/reference/cli/release.md b/docs/reference/cli/release.md index 53955f50..b45951ac 100644 --- a/docs/reference/cli/release.md +++ b/docs/reference/cli/release.md @@ -52,6 +52,8 @@ uplift release --no-prefix commits for the changelog --multiline include multiline commit messages within changelog (skips truncation) + --trim-header trims any lines preceding the conventional commit type + in the commit message --no-prefix strip the default 'v' prefix from the next calculated semantic version --prerelease string append a prerelease suffix to next calculated diff --git a/docs/reference/config.md b/docs/reference/config.md index a120adcb..564dcd9c 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -110,6 +110,10 @@ changelog: # default behaviour of truncating a commit message to its first line multiline: true + # Trims any lines preceding the conventional commit type in the + # commit message + trimHeader: true + # Skips generating a changelog for any prerelease. All commits from # a prerelease will be appended to the changelog entry for the next # release @@ -243,10 +247,10 @@ git: skipTag: true skipBranch: false - # A list of case sensitive files that will be committed to the repo when the - # release is created. Typically these files are managed as part of the - # release process e.g. generated by `hooks`. This allows additional - # information to be included in a release such as a + # A list of case sensitive files that will be committed to the repo when the + # release is created. Typically these files are managed as part of the + # release process e.g. generated by `hooks`. This allows additional + # information to be included in a release such as a # Software Bill Of Materials (SBOM) that is created by an external tool. includeArtifacts: - file.txt diff --git a/docs/static/schema.json b/docs/static/schema.json index 5b8b2682..e5a938a1 100644 --- a/docs/static/schema.json +++ b/docs/static/schema.json @@ -161,6 +161,11 @@ "description": "Include multiline commit messages within the changelog. Disables default behaviour of truncating a commit message to its first line", "type": "boolean" }, + "trimHeader": { + "$comment": "https://upliftci.dev/reference/config#changelog", + "description": "Trims any lines preceding the conventional commit type in the commit message", + "type": "boolean" + }, "skipPrerelease": { "$comment": "https://upliftci.dev/reference/config#changelog", "description": "Skips generating a changelog for any prerelease. All commits from a prerelease will be appended to the changelog entry for the next release", @@ -190,9 +195,14 @@ "multiline" ] }, + { + "required": [ + "trimHeader" + ] + }, { "skipPrerelease": [ - "multiline" + "skipPrerelease" ] } ] diff --git a/internal/config/uplift.go b/internal/config/uplift.go index c6ab3e54..25b20d0f 100644 --- a/internal/config/uplift.go +++ b/internal/config/uplift.go @@ -87,6 +87,7 @@ type Changelog struct { Include []string `yaml:"include" validate:"required_without_all=Sort Exclude,dive,min=1"` Multiline bool `yaml:"multiline"` SkipPrerelease bool `yaml:"skipPrerelease"` + TrimHeader bool `yaml:"trimHeader"` } // Git defines configuration for how uplift interacts with git diff --git a/internal/context/context.go b/internal/context/context.go index 0a7add2d..4eb10d2f 100644 --- a/internal/context/context.go +++ b/internal/context/context.go @@ -92,6 +92,7 @@ type Changelog struct { PreTag bool Multiline bool SkipPrerelease bool + TrimHeader bool } // New constructs a context that captures both runtime configuration and diff --git a/internal/semver/parser.go b/internal/semver/parser.go index ee510b41..e6fb912e 100644 --- a/internal/semver/parser.go +++ b/internal/semver/parser.go @@ -32,6 +32,10 @@ import ( // against a semantic version type Increment string +type ParseOptions struct { + TrimHeader bool +} + const ( // NoIncrement represents no increment change to a semantic version NoIncrement Increment = "None" @@ -56,16 +60,26 @@ const ( // log against the conventional commit standards defined, @see: // https://www.conventionalcommits.org/en/v1.0.0/ func ParseLog(log []git.LogEntry) Increment { + return ParseLogWithOptions(log, ParseOptions{TrimHeader: false}) +} + +func ParseLogWithOptions(log []git.LogEntry, options ParseOptions) Increment { mode := NoIncrement for _, entry := range log { // Check for the existence of a conventional commit type - idx := strings.Index(entry.Message, colonSpace) - if idx == -1 { + colonSpaceIdx := strings.Index(entry.Message, colonSpace) + if colonSpaceIdx == -1 { continue } - leadingType := strings.ToUpper(entry.Message[:idx]) - if leadingType[idx-1] == breakingBang || multilineBreaking(entry.Message) { + startIdx := 0 + // Commit messages may have leading lines before the conventional commit type + if options.TrimHeader { + startIdx = FindStartIdx(entry.Message) + } + + leadingType := strings.ToUpper(entry.Message[startIdx:colonSpaceIdx]) + if leadingType[len(leadingType)-1] == breakingBang || multilineBreaking(entry.Message) { return MajorIncrement } @@ -122,3 +136,18 @@ func multilineBreaking(msg string) bool { return strings.HasPrefix(footer, "BREAKING CHANGE: ") || strings.HasPrefix(footer, "BREAKING-CHANGE: ") } + +func FindStartIdx(msg string) int { + colonIdx := strings.Index(msg, colonSpace) + if colonIdx == -1 { + return 0 + } + + trimmedMsg := msg[:colonIdx] + leadingLineBreakIdx := strings.LastIndex(trimmedMsg, "\n") + if leadingLineBreakIdx == -1 { + return 0 + } + + return leadingLineBreakIdx + 1 +} diff --git a/internal/semver/parser_test.go b/internal/semver/parser_test.go index 3f669e1d..add82e66 100644 --- a/internal/semver/parser_test.go +++ b/internal/semver/parser_test.go @@ -138,3 +138,16 @@ refactor: tidy up some bits of the code`, inc := ParseLog(log) assert.Equal(t, PatchIncrement, inc) } + +func TestParseLog_TrimHeader(t *testing.T) { + log := []git.LogEntry{ + { + Message: `this line that should be ignored +this line that should also be ignored +feat: shiny new feature has been added`, + }, + } + + inc := ParseLogWithOptions(log, ParseOptions{TrimHeader: true}) + assert.Equal(t, MinorIncrement, inc) +} diff --git a/internal/task/changelog/changelog.go b/internal/task/changelog/changelog.go index d2086a2a..e6ec8208 100644 --- a/internal/task/changelog/changelog.go +++ b/internal/task/changelog/changelog.go @@ -154,6 +154,10 @@ func (t Task) Run(ctx *context.Context) error { for i := range rels { for j := range rels[i].Changes { msg := rels[i].Changes[j].Message + if ctx.Changelog.TrimHeader { + startIdx := semver.FindStartIdx(msg) + msg = msg[startIdx:] + } msg = strings.ReplaceAll(msg, "\n", "\n ") msg = strings.ReplaceAll(msg, "\n \n", "\n\n") @@ -165,8 +169,13 @@ func (t Task) Run(ctx *context.Context) error { for i := range rels { for j := range rels[i].Changes { msg := rels[i].Changes[j].Message + if ctx.Changelog.TrimHeader { + startIdx := semver.FindStartIdx(msg) + msg = msg[startIdx:] + rels[i].Changes[j].Message = msg + } if idx := strings.Index(msg, "\n"); idx > -1 { - rels[i].Changes[j].Message = strings.TrimSpace(msg[:idx]) + rels[i].Changes[j].Message = strings.TrimSpace(msg) } } } diff --git a/internal/task/changelog/changelog_test.go b/internal/task/changelog/changelog_test.go index 321d76e4..5b608933 100644 --- a/internal/task/changelog/changelog_test.go +++ b/internal/task/changelog/changelog_test.go @@ -736,7 +736,7 @@ ci: tweak } func TestRun_MultilineMessages(t *testing.T) { - log := `> (tag: 1.1.0) feat: this is a multiline commmit + log := `> (tag: 1.1.0) feat: this is a multiline commit That should be displayed across multiple lines within the changelog. It should be formatted as expected. @@ -770,7 +770,7 @@ With the correct indentation for rendering in markdown expected := fmt.Sprintf(`## 1.1.0 - %s -- %s feat: this is a multiline commmit +- %s feat: this is a multiline commit That should be displayed across multiple lines within the changelog. It should be formatted as expected. @@ -946,3 +946,42 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), assert.Equal(t, expected, readChangelog(t)) } + +func TestRun_TrimHeader(t *testing.T) { + log := `> (tag: 1.1.0) feat: this is a commit +>this line that should be ignored +this line that should also be ignored +feat: second commit +> (tag: 1.0.0) not included in changelog` + gittest.InitRepository(t, gittest.WithLog(log)) + glog := gittest.Log(t) + + var buf bytes.Buffer + ctx := &context.Context{ + Out: &buf, + Changelog: context.Changelog{ + TrimHeader: true, + DiffOnly: true, + }, + CurrentVersion: semver.Version{ + Raw: "1.0.0", + }, + NextVersion: semver.Version{ + Raw: "1.1.0", + }, + SCM: context.SCM{ + Provider: context.Unrecognised, + }, + } + + err := Task{}.Run(ctx) + require.NoError(t, err) + + expected := fmt.Sprintf(`## 1.1.0 - %s + +- %s feat: this is a commit +- %s feat: second commit +`, changelogDate(t), fmt.Sprintf("`%s`", glog[0].AbbrevHash), fmt.Sprintf("`%s`", glog[1].AbbrevHash)) + + assert.Equal(t, expected, buf.String()) +} diff --git a/internal/task/nextsemver/nextsemver.go b/internal/task/nextsemver/nextsemver.go index d9391f99..11ecffd6 100644 --- a/internal/task/nextsemver/nextsemver.go +++ b/internal/task/nextsemver/nextsemver.go @@ -72,7 +72,7 @@ func (t Task) Run(ctx *context.Context) error { } // Identify any commit that will trigger the largest semantic version bump - inc := semver.ParseLog(glog.Commits) + inc := semver.ParseLogWithOptions(glog.Commits, semver.ParseOptions{TrimHeader: ctx.Changelog.TrimHeader}) if inc == semver.NoIncrement { ctx.NoVersionChanged = true