diff --git a/.gitignore b/.gitignore index 33401c28..dc7a887b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ *.dll *.so *.dylib +/uplift # Test binary, built with `go test -c` *.test diff --git a/cmd/uplift/bump_test.go b/cmd/uplift/bump_test.go index 1f424490..adbe8b47 100644 --- a/cmd/uplift/bump_test.go +++ b/cmd/uplift/bump_test.go @@ -49,17 +49,10 @@ docs: update docs feat: a new feature fix: a bug fix (tag: 0.1.0) feat: this was the last feature` - gittest.InitRepository(t, gittest.WithLog(log)) - - // TODO: - // gittest.StagedFile(t, "", "") > TempFile and StageFile under the covers - // gittest.WithCommittedFiles("", "", "", "") > file is automatically committed at the end - - gittest.TempFile(t, "test.txt", bumpFile) - gittest.StageFile(t, "test.txt") - gittest.TempFile(t, ".uplift.yml", bumpConfig) - gittest.StageFile(t, ".uplift.yml") - gittest.Commit(t, "chore: added files") + gittest.InitRepository(t, + gittest.WithLog(log), + gittest.WithCommittedFiles("test.txt", ".uplift.yml"), + gittest.WithFileContent("test.txt", bumpFile, ".uplift.yml", bumpConfig)) bmpCmd := newBumpCmd(noChangesPushed(), os.Stdout) @@ -77,12 +70,10 @@ func TestBump_PrereleaseFlag(t *testing.T) { fix: fix bug feat!: breaking change feat: this is a new feature` - gittest.InitRepository(t, gittest.WithLog(log)) - gittest.TempFile(t, "test.txt", bumpFile) - gittest.StageFile(t, "test.txt") - gittest.TempFile(t, ".uplift.yml", bumpConfig) - gittest.StageFile(t, ".uplift.yml") - gittest.Commit(t, "chore: added files") + gittest.InitRepository(t, + gittest.WithLog(log), + gittest.WithCommittedFiles("test.txt", ".uplift.yml"), + gittest.WithFileContent("test.txt", bumpFile, ".uplift.yml", bumpConfig)) bmpCmd := newBumpCmd(&globalOptions{}, os.Stdout) bmpCmd.Cmd.SetArgs([]string{"--prerelease", "-beta.1+12345"}) diff --git a/cmd/uplift/changelog.go b/cmd/uplift/changelog.go index 10bdd09b..7af09774 100644 --- a/cmd/uplift/changelog.go +++ b/cmd/uplift/changelog.go @@ -77,15 +77,19 @@ uplift changelog --include "^.*\(scope\)" # Generate the next changelog entry but do not stage or push any changes # back to the git remote -uplift changelog --no-stage` +uplift changelog --no-stage + +# Generate a changelog with multiline commit messages +uplift changelog --multiline` ) type changelogOptions struct { - DiffOnly bool - Exclude []string - Include []string - All bool - Sort string + DiffOnly bool + Exclude []string + Include []string + All bool + Sort string + Multiline bool *globalOptions } @@ -126,6 +130,7 @@ func newChangelogCmd(gopts *globalOptions, out io.Writer) *changelogCommand { f.StringSliceVar(&chglogCmd.Opts.Exclude, "exclude", []string{}, "a list of regexes for excluding conventional commits from the changelog") f.StringSliceVar(&chglogCmd.Opts.Include, "include", []string{}, "a list of regexes to cherry-pick conventional commits for the changelog") 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)") chglogCmd.Cmd = cmd return chglogCmd @@ -185,6 +190,10 @@ func setupChangelogContext(opts changelogOptions, out io.Writer) (*context.Conte ctx.NoStage = opts.NoStage ctx.Changelog.DiffOnly = opts.DiffOnly ctx.Changelog.All = opts.All + ctx.Changelog.Multiline = opts.Multiline + if !ctx.Changelog.Multiline && ctx.Config.Changelog != nil { + ctx.Changelog.Multiline = ctx.Config.Changelog.Multiline + } // Sort order provided as a command-line flag takes precedence ctx.Changelog.Sort = opts.Sort diff --git a/cmd/uplift/changelog_test.go b/cmd/uplift/changelog_test.go index 47b818cc..78e26fc3 100644 --- a/cmd/uplift/changelog_test.go +++ b/cmd/uplift/changelog_test.go @@ -283,3 +283,31 @@ func TestChangelog_Hooks(t *testing.T) { assert.FileExists(t, AfterChangelogFile) assert.FileExists(t, AfterFile) } + +func TestChangelog_WithMultiline(t *testing.T) { + log := `> (tag: 2.0.0) feat: this is a multiline commit +The entire contents of this commit should exist in the changelog. + +Multiline formatting should be correct for rendering in markdown +> fix: this is a bug fix +> docs: update documentation +this now includes code examples` + gittest.InitRepository(t, gittest.WithLog(log)) + + chglogCmd := newChangelogCmd(noChangesPushed(), os.Stdout) + chglogCmd.Cmd.SetArgs([]string{"--multiline"}) + + err := chglogCmd.Cmd.Execute() + require.NoError(t, err) + + assert.True(t, changelogExists(t)) + + cl := readChangelog(t) + assert.Contains(t, cl, `feat: this is a multiline commit + The entire contents of this commit should exist in the changelog. + + Multiline formatting should be correct for rendering in markdown`) + assert.Contains(t, cl, "fix: this is a bug fix") + assert.Contains(t, cl, `docs: update documentation + this now includes code examples`) +} diff --git a/cmd/uplift/release.go b/cmd/uplift/release.go index fa295867..4a79e631 100644 --- a/cmd/uplift/release.go +++ b/cmd/uplift/release.go @@ -92,6 +92,7 @@ type releaseOptions struct { Exclude []string Include []string Sort string + Multiline bool *globalOptions } @@ -133,6 +134,7 @@ func newReleaseCmd(gopts *globalOptions, out io.Writer) *releaseCommand { f.StringSliceVar(&relCmd.Opts.Exclude, "exclude", []string{}, "a list of regexes for excluding conventional commits from the changelog") f.StringSliceVar(&relCmd.Opts.Include, "include", []string{}, "a list of regexes to cherry-pick conventional commits for the changelog") 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)") relCmd.Cmd = cmd return relCmd @@ -191,6 +193,10 @@ func setupReleaseContext(opts releaseOptions, out io.Writer) (*context.Context, ctx.Changelog.PreTag = true // Merge config and command line arguments together + ctx.Changelog.Multiline = opts.Multiline + if !ctx.Changelog.Multiline && ctx.Config.Changelog != nil { + ctx.Changelog.Multiline = ctx.Config.Changelog.Multiline + } ctx.Changelog.Include = opts.Include ctx.Changelog.Exclude = opts.Exclude if ctx.Config.Changelog != nil { diff --git a/cmd/uplift/release_test.go b/cmd/uplift/release_test.go index 3566a4f4..37e17525 100644 --- a/cmd/uplift/release_test.go +++ b/cmd/uplift/release_test.go @@ -36,13 +36,10 @@ func TestRelease(t *testing.T) { fix: bug fix docs: update docs ci: update pipeline` - gittest.InitRepository(t, gittest.WithLog(log)) - - gittest.TempFile(t, "test.txt", bumpFile) - gittest.StageFile(t, "test.txt") - gittest.TempFile(t, ".uplift.yml", bumpConfig) - gittest.StageFile(t, ".uplift.yml") - gittest.Commit(t, "chore: added files") + gittest.InitRepository(t, + gittest.WithLog(log), + gittest.WithCommittedFiles("test.txt", ".uplift.yml"), + gittest.WithFileContent("test.txt", bumpFile, ".uplift.yml", bumpConfig)) relCmd := newReleaseCmd(noChangesPushed(), os.Stdout) @@ -120,13 +117,10 @@ func TestRelease_PrereleaseFlag(t *testing.T) { log := `refactor: make changes feat: new feature docs: update docs` - gittest.InitRepository(t, gittest.WithLog(log)) - - gittest.TempFile(t, "test.txt", bumpFile) - gittest.StageFile(t, "test.txt") - gittest.TempFile(t, ".uplift.yml", bumpConfig) - gittest.StageFile(t, ".uplift.yml") - gittest.Commit(t, "chore: added files") + gittest.InitRepository(t, + gittest.WithLog(log), + gittest.WithCommittedFiles("test.txt", ".uplift.yml"), + gittest.WithFileContent("test.txt", bumpFile, ".uplift.yml", bumpConfig)) relCmd := newReleaseCmd(noChangesPushed(), os.Stdout) relCmd.Cmd.SetArgs([]string{"--prerelease", "-beta.1+12345"}) @@ -166,13 +160,10 @@ func TestRelease_SkipBumps(t *testing.T) { fix: bug fix ci: updated workflow (tag: 1.0.0) feat: first feature` - gittest.InitRepository(t, gittest.WithLog(log)) - - gittest.TempFile(t, "test.txt", bumpFile) - gittest.StageFile(t, "test.txt") - gittest.TempFile(t, ".uplift.yml", bumpConfig) - gittest.StageFile(t, ".uplift.yml") - gittest.Commit(t, "chore: added files") + gittest.InitRepository(t, + gittest.WithLog(log), + gittest.WithCommittedFiles("test.txt", ".uplift.yml"), + gittest.WithFileContent("test.txt", bumpFile, ".uplift.yml", bumpConfig)) relCmd := newReleaseCmd(noChangesPushed(), os.Stdout) relCmd.Cmd.SetArgs([]string{"--skip-bumps"}) @@ -270,3 +261,31 @@ feat: a new feat` assert.NotContains(t, cl, "ci: a ci task") assert.NotContains(t, cl, "docs: some new docs") } + +func TestRelease_WithMultiline(t *testing.T) { + log := `> feat: this is a multiline commit +The entire contents of this commit should exist in the changelog. + +Multiline formatting should be correct for rendering in markdown +> fix: this is a bug fix +> docs: update documentation +this now includes code examples` + gittest.InitRepository(t, gittest.WithLog(log)) + + relCmd := newReleaseCmd(noChangesPushed(), os.Stdout) + relCmd.Cmd.SetArgs([]string{"--multiline"}) + + err := relCmd.Cmd.Execute() + require.NoError(t, err) + + assert.True(t, changelogExists(t)) + + cl := readChangelog(t) + assert.Contains(t, cl, `feat: this is a multiline commit + The entire contents of this commit should exist in the changelog. + + Multiline formatting should be correct for rendering in markdown`) + assert.Contains(t, cl, "fix: this is a bug fix") + assert.Contains(t, cl, `docs: update documentation + this now includes code examples`) +} diff --git a/docs/changelog.md b/docs/changelog.md index 0a74c4d5..e6e983db 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -40,10 +40,16 @@ uplift changelog --diff-only ## Migrate an Existing Repository -:octicons-beaker-24: Experimental - If your repository does not contain a `CHANGELOG.md` file, you can generate one that spans its entire history. A word of warning, this does require a tagging structure to be in place. ```sh uplift changelog --all ``` + +## Supporting Multiline Commits + +You can configure `uplift` to include multiline commit messages within your changelog by changing its default behaviour to truncate them to a single line. + +```sh +uplift changelog --multiline +``` diff --git a/docs/reference/cli/changelog.md b/docs/reference/cli/changelog.md index bcc981cb..821651f5 100644 --- a/docs/reference/cli/changelog.md +++ b/docs/reference/cli/changelog.md @@ -45,6 +45,9 @@ uplift changelog --include "^.*\(scope\)" # Generate the next changelog entry but do not stage or push any changes # back to the git remote uplift changelog --no-stage + +# Generate a changelog with multiline commit messages +uplift changelog --multiline ``` ## Flags @@ -58,6 +61,8 @@ uplift changelog --no-stage -h, --help help for changelog --include strings a list of regexes to cherry-pick conventional commits for the changelog + --multiline include multiline commit messages within changelog + (skips truncation) --sort string the sort order of commits within each changelog entry ``` diff --git a/docs/reference/cli/release.md b/docs/reference/cli/release.md index e31d10b1..98a5d9ce 100644 --- a/docs/reference/cli/release.md +++ b/docs/reference/cli/release.md @@ -50,6 +50,8 @@ uplift release --no-prefix -h, --help help for release --include strings a list of regexes to cherry-pick conventional commits for the changelog + --multiline include multiline commit messages within changelog + (skips truncation) --no-prefix strip the default 'v' prefix from the next calculated semantic version --prerelease string append a prerelease suffix to next calculated semantic diff --git a/docs/reference/config.md b/docs/reference/config.md index 520987ff..93f5bf74 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -105,6 +105,10 @@ changelog: # commits that are to be included include: - '^.*\(scope\)' + + # Include multiline commit messages within the changelog. Disables + # default behaviour of truncating a commit message to its first line + multiline: true ``` ## commitAuthor diff --git a/docs/static/schema.json b/docs/static/schema.json index 0b19a703..17a19cd1 100644 --- a/docs/static/schema.json +++ b/docs/static/schema.json @@ -33,7 +33,9 @@ }, "type": "object", "additionalProperties": false, - "required": ["file"], + "required": [ + "file" + ], "not": { "properties": { "regex": { @@ -67,7 +69,9 @@ }, "type": "object", "additionalProperties": false, - "required": ["pattern"] + "required": [ + "pattern" + ] }, "JSONBump": { "properties": { @@ -85,7 +89,9 @@ }, "type": "object", "additionalProperties": false, - "required": ["path"] + "required": [ + "path" + ] }, "CommitAuthor": { "properties": { @@ -106,10 +112,14 @@ "additionalProperties": false, "anyOf": [ { - "required": ["name"] + "required": [ + "name" + ] }, { - "required": ["email"] + "required": [ + "email" + ] } ] }, @@ -119,7 +129,12 @@ "$comment": "https://upliftci.dev/reference/config#changelog", "description": "Change the sort order of the commits within each changelog entry. Supported values are [asc, desc, ASC or DESC]. Defaults to desc (descending order) to mirror the default behaviour of 'git log'", "type": "string", - "enum": ["asc", "desc", "ASC", "DESC"] + "enum": [ + "asc", + "desc", + "ASC", + "DESC" + ] }, "exclude": { "$comment": "https://upliftci.dev/reference/config#changelog", @@ -140,19 +155,35 @@ }, "type": "array", "minItems": 1 + }, + "multiline": { + "$comment": "https://upliftci.dev/reference/config#changelog", + "description": "Include multiline commit messages within the changelog. Disables default behaviour of truncating a commit message to its first line", + "type": "boolean" } }, "type": "object", "additionalProperties": false, "anyOf": [ { - "required": ["sort"] + "required": [ + "sort" + ] }, { - "required": ["exclude"] + "required": [ + "exclude" + ] }, { - "required": ["include"] + "required": [ + "include" + ] + }, + { + "required": [ + "multiline" + ] } ] }, @@ -182,13 +213,19 @@ "additionalProperties": false, "anyOf": [ { - "required": ["ignoreDetached"] + "required": [ + "ignoreDetached" + ] }, { - "required": ["ignoreShallow"] + "required": [ + "ignoreShallow" + ] }, { - "required": ["pushOptions"] + "required": [ + "pushOptions" + ] } ] }, @@ -220,7 +257,9 @@ }, "type": "object", "additionalProperties": false, - "required": ["option"] + "required": [ + "option" + ] } ] }, @@ -235,7 +274,9 @@ }, "type": "object", "additionalProperties": false, - "required": ["url"] + "required": [ + "url" + ] }, "GitHub": { "properties": { @@ -248,7 +289,9 @@ }, "type": "object", "additionalProperties": false, - "required": ["url"] + "required": [ + "url" + ] }, "GitLab": { "properties": { @@ -261,7 +304,9 @@ }, "type": "object", "additionalProperties": false, - "required": ["url"] + "required": [ + "url" + ] }, "Hooks": { "properties": { @@ -350,28 +395,44 @@ "additionalProperties": false, "anyOf": [ { - "required": ["before"] + "required": [ + "before" + ] }, { - "required": ["beforeBump"] + "required": [ + "beforeBump" + ] }, { - "required": ["beforeTag"] + "required": [ + "beforeTag" + ] }, { - "required": ["beforeChangelog"] + "required": [ + "beforeChangelog" + ] }, { - "required": ["after"] + "required": [ + "after" + ] }, { - "required": ["afterBump"] + "required": [ + "afterBump" + ] }, { - "required": ["afterTag"] + "required": [ + "afterTag" + ] }, { - "required": ["afterChangelog"] + "required": [ + "afterChangelog" + ] } ] } @@ -441,19 +502,29 @@ { "oneOf": [ { - "required": ["gitea"] + "required": [ + "gitea" + ] }, { - "required": ["github"] + "required": [ + "github" + ] }, { - "required": ["gitlab"] + "required": [ + "gitlab" + ] } ] }, { "not": { - "required": ["gitea", "github", "gitlab"] + "required": [ + "gitea", + "github", + "gitlab" + ] } } ] diff --git a/internal/config/uplift.go b/internal/config/uplift.go index 11e1db40..22bd1069 100644 --- a/internal/config/uplift.go +++ b/internal/config/uplift.go @@ -82,9 +82,10 @@ type CommitAuthor struct { // Changelog defines configuration for generating a changelog of the latest // semantic version based release type Changelog struct { - Sort string `yaml:"sort" validate:"required_without_all=Exclude Include,oneof=asc desc ASC DESC"` - Exclude []string `yaml:"exclude" validate:"required_without_all=Sort Include,dive,min=1"` - Include []string `yaml:"include" validate:"required_without_all=Sort Exclude,dive,min=1"` + Sort string `yaml:"sort" validate:"required_without_all=Exclude Include,oneof=asc desc ASC DESC"` + Exclude []string `yaml:"exclude" validate:"required_without_all=Sort Include,dive,min=1"` + Include []string `yaml:"include" validate:"required_without_all=Sort Exclude,dive,min=1"` + Multiline bool `yaml:"multiline"` } // Git defines configuration for how uplift interacts with git diff --git a/internal/context/context.go b/internal/context/context.go index 9ac39fb9..777f1b14 100644 --- a/internal/context/context.go +++ b/internal/context/context.go @@ -83,12 +83,13 @@ type SCM struct { // Changelog provides details about how the changelog should be managed // for the current repository type Changelog struct { - All bool - DiffOnly bool - Exclude []string - Include []string - Sort string - PreTag bool + All bool + DiffOnly bool + Exclude []string + Include []string + Sort string + PreTag bool + Multiline bool } // New constructs a context that captures both runtime configuration and diff --git a/internal/task/changelog/changelog.go b/internal/task/changelog/changelog.go index 963f97f8..1830d1a9 100644 --- a/internal/task/changelog/changelog.go +++ b/internal/task/changelog/changelog.go @@ -140,6 +140,29 @@ func (t Task) Run(ctx *context.Context) error { return nil } + if ctx.Changelog.Multiline { + log.Info("formatting multiline messages for changelog") + for i := range rels { + for j := range rels[i].Changes { + msg := rels[i].Changes[j].Message + msg = strings.ReplaceAll(msg, "\n", "\n ") + msg = strings.ReplaceAll(msg, "\n \n", "\n\n") + + rels[i].Changes[j].Message = msg + } + } + } else { + log.Info("trim all commit messages to a single line") + for i := range rels { + for j := range rels[i].Changes { + msg := rels[i].Changes[j].Message + if idx := strings.Index(msg, "\n"); idx > -1 { + rels[i].Changes[j].Message = strings.TrimSpace(msg[:idx]) + } + } + } + } + if ctx.Changelog.DiffOnly { diff, err := diffChangelog(rels) if err != nil { diff --git a/internal/task/changelog/changelog_test.go b/internal/task/changelog/changelog_test.go index 2858f5e6..4940eae8 100644 --- a/internal/task/changelog/changelog_test.go +++ b/internal/task/changelog/changelog_test.go @@ -723,3 +723,50 @@ ci: tweak assert.NotContains(t, actual, "ci: tweak") assert.NotContains(t, actual, "fix(scope1): a fix") } + +func TestRun_MultilineMessages(t *testing.T) { + log := `> (tag: 1.1.0) feat: this is a multiline commmit + +That should be displayed across multiple lines within the changelog. +It should be formatted as expected. + +With the correct indentation for rendering in markdown +> feat: this is a single line commit that remains unchanged +> (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{ + DiffOnly: true, + Multiline: 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 multiline commmit + + That should be displayed across multiple lines within the changelog. + It should be formatted as expected. + + With the correct indentation for rendering in markdown +- %s feat: this is a single line commit that remains unchanged +`, changelogDate(t), fmt.Sprintf("`%s`", glog[0].AbbrevHash), fmt.Sprintf("`%s`", glog[1].AbbrevHash)) + + assert.Equal(t, expected, buf.String()) +}