├── cmd
└── git-sv
│ ├── resources
│ └── templates
│ │ ├── changelog-md.tpl
│ │ ├── rn-md-section-breaking-changes.tpl
│ │ ├── rn-md-section-commits.tpl
│ │ └── releasenotes-md.tpl
│ ├── log.go
│ ├── resources_test.go
│ ├── config_test.go
│ ├── prompt.go
│ ├── config.go
│ ├── main.go
│ └── handlers.go
├── .github
├── dependabot.yml
└── workflows
│ ├── pull-request.yml
│ └── ci.yml
├── .gitignore
├── sv
├── formatter_functions.go
├── git_test.go
├── helpers_test.go
├── formatter_functions_test.go
├── formatter.go
├── releasenotes_test.go
├── config.go
├── semver.go
├── semver_test.go
├── formatter_test.go
├── releasenotes.go
├── git.go
├── message.go
└── message_test.go
├── .golangci.yml
├── go.mod
├── .sv4git.yml
├── LICENSE
├── Makefile
├── go.sum
└── README.md
/cmd/git-sv/resources/templates/changelog-md.tpl:
--------------------------------------------------------------------------------
1 | # Changelog
2 | {{- range .}}
3 |
4 | {{template "releasenotes-md.tpl" .}}
5 | ---
6 | {{- end}}
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "gomod"
4 | directory: "/"
5 | schedule:
6 | interval: "daily"
--------------------------------------------------------------------------------
/cmd/git-sv/resources/templates/rn-md-section-breaking-changes.tpl:
--------------------------------------------------------------------------------
1 | {{- if ne .Name ""}}
2 |
3 | ### {{.Name}}
4 | {{range $k,$v := .Messages}}
5 | - {{$v}}
6 | {{- end}}
7 | {{- end}}
--------------------------------------------------------------------------------
/cmd/git-sv/log.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | )
7 |
8 | func warnf(format string, values ...interface{}) {
9 | fmt.Fprintf(os.Stderr, "WARN: "+format+"\n", values...)
10 | }
11 |
--------------------------------------------------------------------------------
/cmd/git-sv/resources/templates/rn-md-section-commits.tpl:
--------------------------------------------------------------------------------
1 | {{- if .}}{{- if ne .SectionName ""}}
2 |
3 | ### {{.SectionName}}
4 | {{range $k,$v := .Items}}
5 | - {{if $v.Message.Scope}}**{{$v.Message.Scope}}:** {{end}}{{$v.Message.Description}} ({{$v.Hash}}){{if $v.Message.Metadata.issue}} ({{$v.Message.Metadata.issue}}){{end}}
6 | {{- end}}
7 | {{- end}}{{- end}}
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries for programs and plugins
2 | bin/
3 | *.exe
4 | *.exe~
5 | *.dll
6 | *.so
7 | *.dylib
8 |
9 | # Test binary, build with `go test -c`
10 | *.test
11 |
12 | # Output of the go coverage tool, specifically when used with LiteIDE
13 | *.out
14 |
15 | *.sample
16 | todo
17 |
18 | # Additional generated artifacts
19 | artifacts/
20 |
21 | # Mac metadata
22 |
23 | .DS_Store
24 |
--------------------------------------------------------------------------------
/sv/formatter_functions.go:
--------------------------------------------------------------------------------
1 | package sv
2 |
3 | import "time"
4 |
5 | func timeFormat(t time.Time, format string) string {
6 | if t.IsZero() {
7 | return ""
8 | }
9 | return t.Format(format)
10 | }
11 |
12 | func getSection(sections []ReleaseNoteSection, name string) ReleaseNoteSection {
13 | for _, section := range sections {
14 | if section.SectionName() == name {
15 | return section
16 | }
17 | }
18 | return nil
19 | }
20 |
--------------------------------------------------------------------------------
/cmd/git-sv/resources/templates/releasenotes-md.tpl:
--------------------------------------------------------------------------------
1 | ## {{if .Release}}{{.Release}}{{end}}{{if and (not .Date.IsZero) .Release}} ({{end}}{{timefmt .Date "2006-01-02"}}{{if and (not .Date.IsZero) .Release}}){{end}}
2 | {{- range $section := .Sections }}
3 | {{- if (eq $section.SectionType "commits") }}
4 | {{- template "rn-md-section-commits.tpl" $section }}
5 | {{- else if (eq $section.SectionType "breaking-changes")}}
6 | {{- template "rn-md-section-breaking-changes.tpl" $section }}
7 | {{- end}}
8 | {{- end}}
9 |
--------------------------------------------------------------------------------
/cmd/git-sv/resources_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "testing"
5 | )
6 |
7 | func Test_checkTemplatesFiles(t *testing.T) {
8 | tests := []string{
9 | "resources/templates/changelog-md.tpl",
10 | "resources/templates/releasenotes-md.tpl",
11 | }
12 | for _, tt := range tests {
13 | t.Run(tt, func(t *testing.T) {
14 | got, err := defaultTemplatesFS.ReadFile(tt)
15 | if err != nil {
16 | t.Errorf("missing template error = %v", err)
17 | return
18 | }
19 | if len(got) <= 0 {
20 | t.Errorf("empty template")
21 | }
22 | })
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/.golangci.yml:
--------------------------------------------------------------------------------
1 | linters:
2 | enable:
3 | - tagliatelle
4 |
5 | run:
6 | skip-dirs:
7 | - build
8 | - artifacts
9 |
10 | linters-settings:
11 | tagliatelle:
12 | case:
13 | use-field-name: true
14 | rules:
15 | json: camel
16 | yaml: kebab
17 | xml: camel
18 | bson: camel
19 | avro: snake
20 | mapstructure: kebab
21 |
22 | issues:
23 | exclude-rules:
24 | - path: _test\.go
25 | linters:
26 | - gocyclo
27 | - errcheck
28 | - dupl
29 | - gosec
30 | - gochecknoglobals
31 | - testpackage
32 | - path: cmd/git-sv/main.go
33 | linters:
34 | - gochecknoglobals
35 | - funlen
36 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/bvieira/sv4git/v2
2 |
3 | go 1.19
4 |
5 | require (
6 | github.com/Masterminds/semver/v3 v3.2.0
7 | github.com/imdario/mergo v0.3.13
8 | github.com/kelseyhightower/envconfig v1.4.0
9 | github.com/manifoldco/promptui v0.9.0
10 | github.com/urfave/cli/v2 v2.24.1
11 | gopkg.in/yaml.v3 v3.0.1
12 | )
13 |
14 | require (
15 | github.com/chzyer/readline v1.5.1 // indirect
16 | github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
17 | github.com/kr/pretty v0.3.1 // indirect
18 | github.com/russross/blackfriday/v2 v2.1.0 // indirect
19 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
20 | golang.org/x/sys v0.4.0 // indirect
21 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
22 | )
23 |
--------------------------------------------------------------------------------
/.github/workflows/pull-request.yml:
--------------------------------------------------------------------------------
1 | name: pull_request
2 |
3 | on:
4 | pull_request:
5 | branches: [ master ]
6 | paths-ignore:
7 | - '**/.gitignore'
8 |
9 | jobs:
10 |
11 | lint:
12 | name: Lint
13 | runs-on: ubuntu-latest
14 | steps:
15 | - name: Check out code
16 | uses: actions/checkout@v2
17 | - name: Run golangci lint
18 | uses: golangci/golangci-lint-action@v2
19 | with:
20 | version: latest
21 |
22 | build:
23 | name: Build
24 | runs-on: ubuntu-latest
25 | steps:
26 | - name: Check out code
27 | uses: actions/checkout@v2
28 | - name: Set up Go
29 | uses: actions/setup-go@v2
30 | with:
31 | go-version: ^1.19
32 | id: go
33 | - name: Build
34 | run: make build
35 |
--------------------------------------------------------------------------------
/.sv4git.yml:
--------------------------------------------------------------------------------
1 | version: "1.1"
2 |
3 | versioning:
4 | update-major: []
5 | update-minor: [feat]
6 | update-patch: [build, ci, chore, fix, perf, refactor, test]
7 |
8 | tag:
9 | pattern: "v%d.%d.%d"
10 |
11 | release-notes:
12 | sections:
13 | - name: Features
14 | section-type: commits
15 | commit-types: [feat]
16 | - name: Bug Fixes
17 | section-type: commits
18 | commit-types: [fix]
19 | - name: Misc
20 | section-type: commits
21 | commit-types: [build]
22 | - name: Breaking Changes
23 | section-type: breaking-changes
24 |
25 | commit-message:
26 | footer:
27 | issue:
28 | key: issue
29 | add-value-prefix: "#"
30 | issue:
31 | regex: "#?[0-9]+"
32 |
--------------------------------------------------------------------------------
/sv/git_test.go:
--------------------------------------------------------------------------------
1 | package sv
2 |
3 | import (
4 | "reflect"
5 | "testing"
6 | "time"
7 | )
8 |
9 | func Test_parseTagsOutput(t *testing.T) {
10 | tests := []struct {
11 | name string
12 | input string
13 | want []GitTag
14 | wantErr bool
15 | }{
16 | {"with date", "2020-05-01 18:00:00 -0300#1.0.0", []GitTag{{Name: "1.0.0", Date: date("2020-05-01 18:00:00 -0300")}}, false},
17 | {"without date", "#1.0.0", []GitTag{{Name: "1.0.0", Date: time.Time{}}}, false},
18 | }
19 | for _, tt := range tests {
20 | t.Run(tt.name, func(t *testing.T) {
21 | got, err := parseTagsOutput(tt.input)
22 | if (err != nil) != tt.wantErr {
23 | t.Errorf("parseTagsOutput() error = %v, wantErr %v", err, tt.wantErr)
24 | return
25 | }
26 | if !reflect.DeepEqual(got, tt.want) {
27 | t.Errorf("parseTagsOutput() = %v, want %v", got, tt.want)
28 | }
29 | })
30 | }
31 | }
32 |
33 | func date(input string) time.Time {
34 | t, err := time.Parse("2006-01-02 15:04:05 -0700", input)
35 | if err != nil {
36 | panic(err)
37 | }
38 | return t
39 | }
40 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Beatriz Vieira
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/sv/helpers_test.go:
--------------------------------------------------------------------------------
1 | package sv
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/Masterminds/semver/v3"
7 | )
8 |
9 | func version(v string) *semver.Version {
10 | r, _ := semver.NewVersion(v)
11 | return r
12 | }
13 |
14 | func commitlog(ctype string, metadata map[string]string, author string) GitCommitLog {
15 | breaking := false
16 | if _, found := metadata[breakingChangeMetadataKey]; found {
17 | breaking = true
18 | }
19 | return GitCommitLog{
20 | Message: CommitMessage{
21 | Type: ctype,
22 | Description: "subject text",
23 | IsBreakingChange: breaking,
24 | Metadata: metadata,
25 | },
26 | AuthorName: author,
27 | }
28 | }
29 |
30 | func releaseNote(version *semver.Version, tag string, date time.Time, sections []ReleaseNoteSection, authorsNames map[string]struct{}) ReleaseNote {
31 | return ReleaseNote{
32 | Version: version,
33 | Tag: tag,
34 | Date: date.Truncate(time.Minute),
35 | Sections: sections,
36 | AuthorsNames: authorsNames,
37 | }
38 | }
39 |
40 | func newReleaseNoteCommitsSection(name string, types []string, items []GitCommitLog) ReleaseNoteCommitsSection {
41 | return ReleaseNoteCommitsSection{
42 | Name: name,
43 | Types: types,
44 | Items: items,
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/sv/formatter_functions_test.go:
--------------------------------------------------------------------------------
1 | package sv
2 |
3 | import (
4 | "reflect"
5 | "testing"
6 | "time"
7 | )
8 |
9 | func Test_timeFormat(t *testing.T) {
10 | tests := []struct {
11 | name string
12 | time time.Time
13 | format string
14 | want string
15 | }{
16 | {"valid time", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), "2006-01-02", "2022-01-01"},
17 | {"empty time", time.Time{}, "2006-01-02", ""},
18 | }
19 | for _, tt := range tests {
20 | t.Run(tt.name, func(t *testing.T) {
21 | if got := timeFormat(tt.time, tt.format); got != tt.want {
22 | t.Errorf("timeFormat() = %v, want %v", got, tt.want)
23 | }
24 | })
25 | }
26 | }
27 |
28 | func Test_getSection(t *testing.T) {
29 | tests := []struct {
30 | name string
31 | sections []ReleaseNoteSection
32 | sectionName string
33 | want ReleaseNoteSection
34 | }{
35 | {"existing section", []ReleaseNoteSection{ReleaseNoteCommitsSection{Name: "section 0"}, ReleaseNoteCommitsSection{Name: "section 1"}, ReleaseNoteCommitsSection{Name: "section 2"}}, "section 1", ReleaseNoteCommitsSection{Name: "section 1"}},
36 | {"nonexisting section", []ReleaseNoteSection{ReleaseNoteCommitsSection{Name: "section 0"}, ReleaseNoteCommitsSection{Name: "section 1"}, ReleaseNoteCommitsSection{Name: "section 2"}}, "section 10", nil},
37 | }
38 | for _, tt := range tests {
39 | t.Run(tt.name, func(t *testing.T) {
40 | if got := getSection(tt.sections, tt.sectionName); !reflect.DeepEqual(got, tt.want) {
41 | t.Errorf("getSection() = %v, want %v", got, tt.want)
42 | }
43 | })
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/sv/formatter.go:
--------------------------------------------------------------------------------
1 | package sv
2 |
3 | import (
4 | "bytes"
5 | "io/fs"
6 | "os"
7 | "sort"
8 | "text/template"
9 | "time"
10 |
11 | "github.com/Masterminds/semver/v3"
12 | )
13 |
14 | type releaseNoteTemplateVariables struct {
15 | Release string
16 | Tag string
17 | Version *semver.Version
18 | Date time.Time
19 | Sections []ReleaseNoteSection
20 | AuthorNames []string
21 | }
22 |
23 | // OutputFormatter output formatter interface.
24 | type OutputFormatter interface {
25 | FormatReleaseNote(releasenote ReleaseNote) (string, error)
26 | FormatChangelog(releasenotes []ReleaseNote) (string, error)
27 | }
28 |
29 | // OutputFormatterImpl formater for release note and changelog.
30 | type OutputFormatterImpl struct {
31 | templates *template.Template
32 | }
33 |
34 | // NewOutputFormatter TemplateProcessor constructor.
35 | func NewOutputFormatter(templatesFS fs.FS) *OutputFormatterImpl {
36 | templateFNs := map[string]interface{}{
37 | "timefmt": timeFormat,
38 | "getsection": getSection,
39 | "getenv": os.Getenv,
40 | }
41 | tpls := template.Must(template.New("templates").Funcs(templateFNs).ParseFS(templatesFS, "*"))
42 | return &OutputFormatterImpl{templates: tpls}
43 | }
44 |
45 | // FormatReleaseNote format a release note.
46 | func (p OutputFormatterImpl) FormatReleaseNote(releasenote ReleaseNote) (string, error) {
47 | var b bytes.Buffer
48 | if err := p.templates.ExecuteTemplate(&b, "releasenotes-md.tpl", releaseNoteVariables(releasenote)); err != nil {
49 | return "", err
50 | }
51 | return b.String(), nil
52 | }
53 |
54 | // FormatChangelog format a changelog.
55 | func (p OutputFormatterImpl) FormatChangelog(releasenotes []ReleaseNote) (string, error) {
56 | templateVars := make([]releaseNoteTemplateVariables, len(releasenotes))
57 | for i, v := range releasenotes {
58 | templateVars[i] = releaseNoteVariables(v)
59 | }
60 |
61 | var b bytes.Buffer
62 | if err := p.templates.ExecuteTemplate(&b, "changelog-md.tpl", templateVars); err != nil {
63 | return "", err
64 | }
65 | return b.String(), nil
66 | }
67 |
68 | func releaseNoteVariables(releasenote ReleaseNote) releaseNoteTemplateVariables {
69 | release := releasenote.Tag
70 | if releasenote.Version != nil {
71 | release = "v" + releasenote.Version.String()
72 | }
73 | return releaseNoteTemplateVariables{
74 | Release: release,
75 | Tag: releasenote.Tag,
76 | Version: releasenote.Version,
77 | Date: releasenote.Date,
78 | Sections: releasenote.Sections,
79 | AuthorNames: toSortedArray(releasenote.AuthorsNames),
80 | }
81 | }
82 |
83 | func toSortedArray(input map[string]struct{}) []string {
84 | result := make([]string, len(input))
85 | i := 0
86 | for k := range input {
87 | result[i] = k
88 | i++
89 | }
90 | sort.Strings(result)
91 | return result
92 | }
93 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: ci
2 |
3 | on:
4 | push:
5 | branches: [master]
6 | paths-ignore:
7 | - "**.md"
8 | - "**/.gitignore"
9 | - ".github/workflows/**"
10 |
11 | jobs:
12 |
13 | lint:
14 | name: Lint
15 | runs-on: ubuntu-latest
16 | steps:
17 | - name: Check out code
18 | uses: actions/checkout@v3
19 | - name: Run golangci lint
20 | uses: golangci/golangci-lint-action@v3
21 | with:
22 | version: latest
23 |
24 | build:
25 | name: Build
26 | runs-on: ubuntu-latest
27 | steps:
28 | - name: Check out code
29 | uses: actions/checkout@v3
30 | - name: Set up Go
31 | uses: actions/setup-go@v3
32 | with:
33 | go-version: ^1.19
34 | - name: Build
35 | run: make build
36 |
37 | tag:
38 | name: Tag
39 | runs-on: ubuntu-latest
40 | needs: [lint, build]
41 | steps:
42 | - name: Check out code
43 | uses: actions/checkout@v3
44 | with:
45 | fetch-depth: 0
46 |
47 | - name: Set GitHub Actions as commit author
48 | shell: bash
49 | run: |
50 | git config user.name "github-actions[bot]"
51 | git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
52 |
53 | - name: Setup sv4git
54 | run: |
55 | curl -s https://api.github.com/repos/bvieira/sv4git/releases/latest | jq -r '.assets[] | select(.browser_download_url | contains("linux")) | .browser_download_url' | wget -O /tmp/sv4git.tar.gz -qi - \
56 | && tar -C /usr/local/bin -xzf /tmp/sv4git.tar.gz
57 |
58 | - name: Create tag
59 | id: create-tag
60 | run: |
61 | git sv tag
62 | VERSION=$(git sv cv)
63 | echo "::set-output name=tag::v$VERSION"
64 | outputs:
65 | tag: ${{ steps.create-tag.outputs.tag }}
66 |
67 | release:
68 | name: Release
69 | runs-on: ubuntu-latest
70 | needs: [tag]
71 | steps:
72 | - name: Check out code
73 | uses: actions/checkout@v3
74 | with:
75 | fetch-depth: 0
76 |
77 | - name: Setup sv4git
78 | run: |
79 | curl -s https://api.github.com/repos/bvieira/sv4git/releases/latest | jq -r '.assets[] | select(.browser_download_url | contains("linux")) | .browser_download_url' | wget -O /tmp/sv4git.tar.gz -qi - \
80 | && tar -C /usr/local/bin -xzf /tmp/sv4git.tar.gz
81 |
82 | - name: Set up Go
83 | id: go
84 | uses: actions/setup-go@v3
85 | with:
86 | go-version: ^1.19
87 |
88 | - name: Create release notes
89 | run: |
90 | git sv rn -t "${{ needs.tag.outputs.tag }}" > release-notes.md
91 |
92 | - name: Build releases
93 | run: make release-all
94 |
95 | - name: Release
96 | uses: softprops/action-gh-release@v1
97 | with:
98 | body_path: release-notes.md
99 | tag_name: ${{ needs.tag.outputs.tag }}
100 | fail_on_unmatched_files: true
101 | files: |
102 | bin/git-sv_*
103 |
--------------------------------------------------------------------------------
/sv/releasenotes_test.go:
--------------------------------------------------------------------------------
1 | package sv
2 |
3 | import (
4 | "reflect"
5 | "testing"
6 | "time"
7 |
8 | "github.com/Masterminds/semver/v3"
9 | )
10 |
11 | func TestReleaseNoteProcessorImpl_Create(t *testing.T) {
12 | date := time.Now()
13 |
14 | tests := []struct {
15 | name string
16 | version *semver.Version
17 | tag string
18 | date time.Time
19 | commits []GitCommitLog
20 | want ReleaseNote
21 | }{
22 | {
23 | name: "mapped tag",
24 | version: semver.MustParse("1.0.0"),
25 | tag: "v1.0.0",
26 | date: date,
27 | commits: []GitCommitLog{commitlog("t1", map[string]string{}, "a")},
28 | want: releaseNote(semver.MustParse("1.0.0"), "v1.0.0", date, []ReleaseNoteSection{newReleaseNoteCommitsSection("Tag 1", []string{"t1"}, []GitCommitLog{commitlog("t1", map[string]string{}, "a")})}, map[string]struct{}{"a": {}}),
29 | },
30 | {
31 | name: "unmapped tag",
32 | version: semver.MustParse("1.0.0"),
33 | tag: "v1.0.0",
34 | date: date,
35 | commits: []GitCommitLog{commitlog("t1", map[string]string{}, "a"), commitlog("unmapped", map[string]string{}, "a")},
36 | want: releaseNote(semver.MustParse("1.0.0"), "v1.0.0", date, []ReleaseNoteSection{newReleaseNoteCommitsSection("Tag 1", []string{"t1"}, []GitCommitLog{commitlog("t1", map[string]string{}, "a")})}, map[string]struct{}{"a": {}}),
37 | },
38 | {
39 | name: "breaking changes tag",
40 | version: semver.MustParse("1.0.0"),
41 | tag: "v1.0.0",
42 | date: date,
43 | commits: []GitCommitLog{commitlog("t1", map[string]string{}, "a"), commitlog("unmapped", map[string]string{"breaking-change": "breaks"}, "a")},
44 | want: releaseNote(semver.MustParse("1.0.0"), "v1.0.0", date, []ReleaseNoteSection{newReleaseNoteCommitsSection("Tag 1", []string{"t1"}, []GitCommitLog{commitlog("t1", map[string]string{}, "a")}), ReleaseNoteBreakingChangeSection{Name: "Breaking Changes", Messages: []string{"breaks"}}}, map[string]struct{}{"a": {}}),
45 | },
46 | {
47 | name: "multiple authors",
48 | version: semver.MustParse("1.0.0"),
49 | tag: "v1.0.0",
50 | date: date,
51 | commits: []GitCommitLog{commitlog("t1", map[string]string{}, "author3"), commitlog("t1", map[string]string{}, "author2"), commitlog("t1", map[string]string{}, "author1")},
52 | want: releaseNote(semver.MustParse("1.0.0"), "v1.0.0", date, []ReleaseNoteSection{newReleaseNoteCommitsSection("Tag 1", []string{"t1"}, []GitCommitLog{commitlog("t1", map[string]string{}, "author3"), commitlog("t1", map[string]string{}, "author2"), commitlog("t1", map[string]string{}, "author1")})}, map[string]struct{}{"author1": {}, "author2": {}, "author3": {}}),
53 | },
54 | }
55 | for _, tt := range tests {
56 | t.Run(tt.name, func(t *testing.T) {
57 | p := NewReleaseNoteProcessor(ReleaseNotesConfig{Sections: []ReleaseNotesSectionConfig{{Name: "Tag 1", SectionType: "commits", CommitTypes: []string{"t1"}}, {Name: "Tag 2", SectionType: "commits", CommitTypes: []string{"t2"}}, {Name: "Breaking Changes", SectionType: "breaking-changes"}}})
58 | if got := p.Create(tt.version, tt.tag, tt.date, tt.commits); !reflect.DeepEqual(got, tt.want) {
59 | t.Errorf("ReleaseNoteProcessorImpl.Create() = %v, want %v", got, tt.want)
60 | }
61 | })
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/sv/config.go:
--------------------------------------------------------------------------------
1 | package sv
2 |
3 | // ==== Message ====
4 |
5 | // CommitMessageConfig config a commit message.
6 | type CommitMessageConfig struct {
7 | Types []string `yaml:"types,flow"`
8 | HeaderSelector string `yaml:"header-selector"`
9 | Scope CommitMessageScopeConfig `yaml:"scope"`
10 | Footer map[string]CommitMessageFooterConfig `yaml:"footer"`
11 | Issue CommitMessageIssueConfig `yaml:"issue"`
12 | }
13 |
14 | // IssueFooterConfig config for issue.
15 | func (c CommitMessageConfig) IssueFooterConfig() CommitMessageFooterConfig {
16 | if v, exists := c.Footer[issueMetadataKey]; exists {
17 | return v
18 | }
19 | return CommitMessageFooterConfig{}
20 | }
21 |
22 | // CommitMessageScopeConfig config scope preferences.
23 | type CommitMessageScopeConfig struct {
24 | Values []string `yaml:"values"`
25 | }
26 |
27 | // CommitMessageFooterConfig config footer metadata.
28 | type CommitMessageFooterConfig struct {
29 | Key string `yaml:"key"`
30 | KeySynonyms []string `yaml:"key-synonyms,flow"`
31 | UseHash bool `yaml:"use-hash"`
32 | AddValuePrefix string `yaml:"add-value-prefix"`
33 | }
34 |
35 | // CommitMessageIssueConfig issue preferences.
36 | type CommitMessageIssueConfig struct {
37 | Regex string `yaml:"regex"`
38 | }
39 |
40 | // ==== Branches ====
41 |
42 | // BranchesConfig branches preferences.
43 | type BranchesConfig struct {
44 | Prefix string `yaml:"prefix"`
45 | Suffix string `yaml:"suffix"`
46 | DisableIssue bool `yaml:"disable-issue"`
47 | Skip []string `yaml:"skip,flow"`
48 | SkipDetached *bool `yaml:"skip-detached"`
49 | }
50 |
51 | // ==== Versioning ====
52 |
53 | // VersioningConfig versioning preferences.
54 | type VersioningConfig struct {
55 | UpdateMajor []string `yaml:"update-major,flow"`
56 | UpdateMinor []string `yaml:"update-minor,flow"`
57 | UpdatePatch []string `yaml:"update-patch,flow"`
58 | IgnoreUnknown bool `yaml:"ignore-unknown"`
59 | }
60 |
61 | // ==== Tag ====
62 |
63 | // TagConfig tag preferences.
64 | type TagConfig struct {
65 | Pattern *string `yaml:"pattern"`
66 | Filter *string `yaml:"filter"`
67 | }
68 |
69 | // ==== Release Notes ====
70 |
71 | // ReleaseNotesConfig release notes preferences.
72 | type ReleaseNotesConfig struct {
73 | Headers map[string]string `yaml:"headers,omitempty"`
74 | Sections []ReleaseNotesSectionConfig `yaml:"sections"`
75 | }
76 |
77 | func (cfg ReleaseNotesConfig) sectionConfig(sectionType string) *ReleaseNotesSectionConfig {
78 | for _, sectionCfg := range cfg.Sections {
79 | if sectionCfg.SectionType == sectionType {
80 | return §ionCfg
81 | }
82 | }
83 | return nil
84 | }
85 |
86 | // ReleaseNotesSectionConfig preferences for a single section on release notes.
87 | type ReleaseNotesSectionConfig struct {
88 | Name string `yaml:"name"`
89 | SectionType string `yaml:"section-type"`
90 | CommitTypes []string `yaml:"commit-types,flow,omitempty"`
91 | }
92 |
93 | const (
94 | // ReleaseNotesSectionTypeCommits ReleaseNotesSectionConfig.SectionType value.
95 | ReleaseNotesSectionTypeCommits = "commits"
96 | // ReleaseNotesSectionTypeBreakingChanges ReleaseNotesSectionConfig.SectionType value.
97 | ReleaseNotesSectionTypeBreakingChanges = "breaking-changes"
98 | )
99 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: usage build lint lint-autofix test test-coverage test-show-coverage run tidy release release-all
2 |
3 | OK_COLOR=\033[32;01m
4 | NO_COLOR=\033[0m
5 | ERROR_COLOR=\033[31;01m
6 | WARN_COLOR=\033[33;01m
7 |
8 | PKGS = $(shell go list ./...)
9 | BIN = git-sv
10 |
11 | ECHOFLAGS ?=
12 |
13 | BUILD_TIME = $(shell date +"%Y%m%d%H%M")
14 | VERSION ?= dev-$(BUILD_TIME)
15 |
16 | BUILDOS ?= linux
17 | BUILDARCH ?= amd64
18 | BUILDENVS ?= CGO_ENABLED=0 GOOS=$(BUILDOS) GOARCH=$(BUILDARCH)
19 | BUILDFLAGS ?= -a -installsuffix cgo --ldflags '-X main.Version=$(VERSION) -extldflags "-lm -lstdc++ -static"'
20 |
21 | COMPRESS_TYPE ?= targz
22 |
23 | usage: Makefile
24 | @echo $(ECHOFLAGS) "to use make call:"
25 | @echo $(ECHOFLAGS) " make "
26 | @echo $(ECHOFLAGS) ""
27 | @echo $(ECHOFLAGS) "list of available actions:"
28 | @sed -n 's/^##//p' $< | column -t -s ':' | sed -e 's/^/ /'
29 |
30 | ## build: build git-sv
31 | build: test
32 | @echo $(ECHOFLAGS) "$(OK_COLOR)==> Building binary ($(BUILDOS)/$(BUILDARCH)/$(BIN))...$(NO_COLOR)"
33 | @$(BUILDENVS) go build -v $(BUILDFLAGS) -o bin/$(BUILDOS)_$(BUILDARCH)/$(BIN) ./cmd/git-sv
34 |
35 | ## lint: run golangci-lint without autofix
36 | lint:
37 | @echo $(ECHOFLAGS) "$(OK_COLOR)==> Running golangci-lint...$(NO_COLOR)"
38 | @golangci-lint run ./... --config .golangci.yml
39 |
40 | ## lint-autofix: run golangci-lint with autofix enabled
41 | lint-autofix:
42 | @echo $(ECHOFLAGS) "$(OK_COLOR)==> Running golangci-lint...$(NO_COLOR)"
43 | @golangci-lint run ./... --config .golangci.yml --fix
44 |
45 | ## test: run unit tests
46 | test:
47 | @echo $(ECHOFLAGS) "$(OK_COLOR)==> Running tests...$(NO_COLOR)"
48 | @go test $(PKGS)
49 |
50 | ## test-coverage: run tests with coverage
51 | test-coverage:
52 | @echo $(ECHOFLAGS) "$(OK_COLOR)==> Running tests with coverage...$(NO_COLOR)"
53 | @go test -race -covermode=atomic -coverprofile coverage.out ./...
54 |
55 | ## test-show-coverage: show coverage
56 | test-show-coverage: test-coverage
57 | @echo $(ECHOFLAGS) "$(OK_COLOR)==> Show test coverage...$(NO_COLOR)"
58 | @go tool cover -html coverage.out
59 |
60 | ## run: run git-sv
61 | run:
62 | @echo $(ECHOFLAGS) "$(OK_COLOR)==> Running bin/$(BUILDOS)_$(BUILDARCH)/$(BIN)...$(NO_COLOR)"
63 | @./bin/$(BUILDOS)_$(BUILDARCH)/$(BIN) $(args)
64 |
65 | ## tidy: execute go mod tidy
66 | tidy:
67 | @echo $(ECHOFLAGS) "$(OK_COLOR)==> runing tidy"
68 | @go mod tidy
69 |
70 | ## release: prepare binary for release
71 | release:
72 | make build
73 | ifeq ($(COMPRESS_TYPE), zip)
74 | @zip -j bin/git-sv_$(VERSION)_$(BUILDOS)_$(BUILDARCH).zip bin/$(BUILDOS)_$(BUILDARCH)/$(BIN)
75 | else
76 | @tar -czf bin/git-sv_$(VERSION)_$(BUILDOS)_$(BUILDARCH).tar.gz -C bin/$(BUILDOS)_$(BUILDARCH)/ $(BIN)
77 | endif
78 |
79 | ## release-all: prepare linux, darwin and windows binary for release (requires sv4git)
80 | release-all:
81 | @rm -rf bin
82 |
83 | VERSION=$(shell git sv nv) BUILDOS=linux BUILDARCH=amd64 make release
84 | VERSION=$(shell git sv nv) BUILDOS=darwin BUILDARCH=amd64 make release
85 | VERSION=$(shell git sv nv) COMPRESS_TYPE=zip BUILDOS=windows BUILDARCH=amd64 make release
86 |
87 | VERSION=$(shell git sv nv) BUILDOS=linux BUILDARCH=arm64 make release
88 | VERSION=$(shell git sv nv) BUILDOS=darwin BUILDARCH=arm64 make release
89 | VERSION=$(shell git sv nv) COMPRESS_TYPE=zip BUILDOS=windows BUILDARCH=arm64 make release
90 |
--------------------------------------------------------------------------------
/sv/semver.go:
--------------------------------------------------------------------------------
1 | package sv
2 |
3 | import "github.com/Masterminds/semver/v3"
4 |
5 | type versionType int
6 |
7 | const (
8 | none versionType = iota
9 | patch
10 | minor
11 | major
12 | )
13 |
14 | // IsValidVersion return true when a version is valid.
15 | func IsValidVersion(value string) bool {
16 | _, err := semver.NewVersion(value)
17 | return err == nil
18 | }
19 |
20 | // ToVersion parse string to semver.Version.
21 | func ToVersion(value string) (*semver.Version, error) {
22 | version := value
23 | if version == "" {
24 | version = "0.0.0"
25 | }
26 | return semver.NewVersion(version)
27 | }
28 |
29 | // SemVerCommitsProcessor interface.
30 | type SemVerCommitsProcessor interface {
31 | NextVersion(version *semver.Version, commits []GitCommitLog) (*semver.Version, bool)
32 | }
33 |
34 | // SemVerCommitsProcessorImpl process versions using commit log.
35 | type SemVerCommitsProcessorImpl struct {
36 | MajorVersionTypes map[string]struct{}
37 | MinorVersionTypes map[string]struct{}
38 | PatchVersionTypes map[string]struct{}
39 | KnownTypes []string
40 | IncludeUnknownTypeAsPatch bool
41 | }
42 |
43 | // NewSemVerCommitsProcessor SemanticVersionCommitsProcessorImpl constructor.
44 | func NewSemVerCommitsProcessor(vcfg VersioningConfig, mcfg CommitMessageConfig) *SemVerCommitsProcessorImpl {
45 | return &SemVerCommitsProcessorImpl{
46 | IncludeUnknownTypeAsPatch: !vcfg.IgnoreUnknown,
47 | MajorVersionTypes: toMap(vcfg.UpdateMajor),
48 | MinorVersionTypes: toMap(vcfg.UpdateMinor),
49 | PatchVersionTypes: toMap(vcfg.UpdatePatch),
50 | KnownTypes: mcfg.Types,
51 | }
52 | }
53 |
54 | // NextVersion calculates next version based on commit log.
55 | func (p SemVerCommitsProcessorImpl) NextVersion(version *semver.Version, commits []GitCommitLog) (*semver.Version, bool) {
56 | versionToUpdate := none
57 | for _, commit := range commits {
58 | if v := p.versionTypeToUpdate(commit); v > versionToUpdate {
59 | versionToUpdate = v
60 | }
61 | }
62 |
63 | updated := versionToUpdate != none
64 | if version == nil {
65 | return nil, updated
66 | }
67 | newVersion := updateVersion(*version, versionToUpdate)
68 | return &newVersion, updated
69 | }
70 |
71 | func updateVersion(version semver.Version, versionToUpdate versionType) semver.Version {
72 | switch versionToUpdate {
73 | case major:
74 | return version.IncMajor()
75 | case minor:
76 | return version.IncMinor()
77 | case patch:
78 | return version.IncPatch()
79 | default:
80 | return version
81 | }
82 | }
83 |
84 | func (p SemVerCommitsProcessorImpl) versionTypeToUpdate(commit GitCommitLog) versionType {
85 | if commit.Message.IsBreakingChange {
86 | return major
87 | }
88 | if _, exists := p.MajorVersionTypes[commit.Message.Type]; exists {
89 | return major
90 | }
91 | if _, exists := p.MinorVersionTypes[commit.Message.Type]; exists {
92 | return minor
93 | }
94 | if _, exists := p.PatchVersionTypes[commit.Message.Type]; exists {
95 | return patch
96 | }
97 | if !contains(commit.Message.Type, p.KnownTypes) && p.IncludeUnknownTypeAsPatch {
98 | return patch
99 | }
100 | return none
101 | }
102 |
103 | func toMap(values []string) map[string]struct{} {
104 | result := make(map[string]struct{})
105 | for _, v := range values {
106 | result[v] = struct{}{}
107 | }
108 | return result
109 | }
110 |
--------------------------------------------------------------------------------
/cmd/git-sv/config_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "reflect"
5 | "testing"
6 |
7 | "github.com/bvieira/sv4git/v2/sv"
8 | )
9 |
10 | func Test_merge(t *testing.T) {
11 | boolFalse := false
12 | boolTrue := true
13 | emptyStr := ""
14 | nonEmptyStr := "something"
15 |
16 | tests := []struct {
17 | name string
18 | dst Config
19 | src Config
20 | want Config
21 | wantErr bool
22 | }{
23 | {"overwrite string", Config{Version: "a"}, Config{Version: "b"}, Config{Version: "b"}, false},
24 | {"default string", Config{Version: "a"}, Config{Version: ""}, Config{Version: "a"}, false},
25 |
26 | {"overwrite list", Config{Branches: sv.BranchesConfig{Skip: []string{"a", "b"}}}, Config{Branches: sv.BranchesConfig{Skip: []string{"c", "d"}}}, Config{Branches: sv.BranchesConfig{Skip: []string{"c", "d"}}}, false},
27 | {"overwrite list with empty", Config{Branches: sv.BranchesConfig{Skip: []string{"a", "b"}}}, Config{Branches: sv.BranchesConfig{Skip: make([]string, 0)}}, Config{Branches: sv.BranchesConfig{Skip: make([]string, 0)}}, false},
28 | {"default list", Config{Branches: sv.BranchesConfig{Skip: []string{"a", "b"}}}, Config{Branches: sv.BranchesConfig{Skip: nil}}, Config{Branches: sv.BranchesConfig{Skip: []string{"a", "b"}}}, false},
29 |
30 | {"overwrite pointer bool false", Config{Branches: sv.BranchesConfig{SkipDetached: &boolFalse}}, Config{Branches: sv.BranchesConfig{SkipDetached: &boolTrue}}, Config{Branches: sv.BranchesConfig{SkipDetached: &boolTrue}}, false},
31 | {"overwrite pointer bool true", Config{Branches: sv.BranchesConfig{SkipDetached: &boolTrue}}, Config{Branches: sv.BranchesConfig{SkipDetached: &boolFalse}}, Config{Branches: sv.BranchesConfig{SkipDetached: &boolFalse}}, false},
32 | {"default pointer bool", Config{Branches: sv.BranchesConfig{SkipDetached: &boolTrue}}, Config{Branches: sv.BranchesConfig{SkipDetached: nil}}, Config{Branches: sv.BranchesConfig{SkipDetached: &boolTrue}}, false},
33 |
34 | {"merge maps", Config{CommitMessage: sv.CommitMessageConfig{Footer: map[string]sv.CommitMessageFooterConfig{"issue": {Key: "jira"}}}}, Config{CommitMessage: sv.CommitMessageConfig{Footer: map[string]sv.CommitMessageFooterConfig{"issue2": {Key: "jira2"}}}}, Config{CommitMessage: sv.CommitMessageConfig{Footer: map[string]sv.CommitMessageFooterConfig{"issue": {Key: "jira"}, "issue2": {Key: "jira2"}}}}, false},
35 | {"default maps", Config{CommitMessage: sv.CommitMessageConfig{Footer: map[string]sv.CommitMessageFooterConfig{"issue": {Key: "jira"}}}}, Config{CommitMessage: sv.CommitMessageConfig{Footer: nil}}, Config{CommitMessage: sv.CommitMessageConfig{Footer: map[string]sv.CommitMessageFooterConfig{"issue": {Key: "jira"}}}}, false},
36 | {"merge empty maps", Config{CommitMessage: sv.CommitMessageConfig{Footer: map[string]sv.CommitMessageFooterConfig{"issue": {Key: "jira"}}}}, Config{CommitMessage: sv.CommitMessageConfig{Footer: map[string]sv.CommitMessageFooterConfig{}}}, Config{CommitMessage: sv.CommitMessageConfig{Footer: map[string]sv.CommitMessageFooterConfig{"issue": {Key: "jira"}}}}, false},
37 |
38 | {"overwrite release notes header", Config{ReleaseNotes: sv.ReleaseNotesConfig{Headers: map[string]string{"a": "aa"}}}, Config{ReleaseNotes: sv.ReleaseNotesConfig{Headers: map[string]string{"b": "bb"}}}, Config{ReleaseNotes: sv.ReleaseNotesConfig{Headers: map[string]string{"b": "bb"}}}, false},
39 |
40 | {"overwrite tag config", Config{Version: "a", Tag: sv.TagConfig{Pattern: &nonEmptyStr, Filter: &nonEmptyStr}}, Config{Version: "", Tag: sv.TagConfig{Pattern: &emptyStr, Filter: &emptyStr}}, Config{Version: "a", Tag: sv.TagConfig{Pattern: &emptyStr, Filter: &emptyStr}}, false},
41 | }
42 | for _, tt := range tests {
43 | t.Run(tt.name, func(t *testing.T) {
44 | if err := merge(&tt.dst, tt.src); (err != nil) != tt.wantErr {
45 | t.Errorf("merge() error = %v, wantErr %v", err, tt.wantErr)
46 | }
47 | if !reflect.DeepEqual(tt.dst, tt.want) {
48 | t.Errorf("merge() = %v, want %v", tt.dst, tt.want)
49 | }
50 | })
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g=
2 | github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
3 | github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
4 | github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
5 | github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
6 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
7 | github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
8 | github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
9 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
10 | github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
11 | github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
12 | github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
13 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
14 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
15 | github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk=
16 | github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg=
17 | github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
18 | github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
19 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
20 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
21 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
22 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
23 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
24 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
25 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
26 | github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
27 | github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
28 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
29 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
30 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
31 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
32 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
33 | github.com/urfave/cli/v2 v2.24.1 h1:/QYYr7g0EhwXEML8jO+8OYt5trPnLHS0p3mrgExJ5NU=
34 | github.com/urfave/cli/v2 v2.24.1/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
35 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
36 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
37 | golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
38 | golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
39 | golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
40 | golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
41 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
42 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
43 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
44 | gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
45 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
46 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
47 |
--------------------------------------------------------------------------------
/cmd/git-sv/prompt.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "reflect"
6 | "regexp"
7 |
8 | "github.com/manifoldco/promptui"
9 | )
10 |
11 | type commitType struct {
12 | Type string
13 | Description string
14 | Example string
15 | }
16 |
17 | func promptType(types []string) (commitType, error) {
18 | defaultTypes := map[string]commitType{
19 | "build": {Type: "build", Description: "changes that affect the build system or external dependencies", Example: "gradle, maven, go mod, npm"},
20 | "ci": {Type: "ci", Description: "changes to our CI configuration files and scripts", Example: "Circle, BrowserStack, SauceLabs"},
21 | "chore": {Type: "chore", Description: "update something without impacting the user", Example: "gitignore"},
22 | "docs": {Type: "docs", Description: "documentation only changes"},
23 | "feat": {Type: "feat", Description: "a new feature"},
24 | "fix": {Type: "fix", Description: "a bug fix"},
25 | "perf": {Type: "perf", Description: "a code change that improves performance"},
26 | "refactor": {Type: "refactor", Description: "a code change that neither fixes a bug nor adds a feature"},
27 | "style": {Type: "style", Description: "changes that do not affect the meaning of the code", Example: "white-space, formatting, missing semi-colons, etc"},
28 | "test": {Type: "test", Description: "adding missing tests or correcting existing tests"},
29 | "revert": {Type: "revert", Description: "revert a single commit"},
30 | }
31 |
32 | var items []commitType
33 | for _, t := range types {
34 | if v, exists := defaultTypes[t]; exists {
35 | items = append(items, v)
36 | } else {
37 | items = append(items, commitType{Type: t})
38 | }
39 | }
40 |
41 | template := &promptui.SelectTemplates{
42 | Label: "{{ . }}",
43 | Active: "> {{ .Type | white }} - {{ .Description | faint }}",
44 | Inactive: " {{ .Type | white }} - {{ .Description | faint }}",
45 | Selected: `{{ "type:" | faint }} {{ .Type | white }}`,
46 | Details: `
47 | {{ "Type:" | faint }} {{ .Type }}
48 | {{ "Description:" | faint }} {{ .Description }}
49 | {{ "Example:" | faint }} {{ .Example }}`,
50 | }
51 |
52 | i, err := promptSelect("type", items, template)
53 | if err != nil {
54 | return commitType{}, err
55 | }
56 | return items[i], nil
57 | }
58 |
59 | func promptScope(values []string) (string, error) {
60 | if len(values) > 0 {
61 | selected, err := promptSelect("scope", values, nil)
62 | if err != nil {
63 | return "", err
64 | }
65 | return values[selected], nil
66 | }
67 | return promptText("scope", "^[a-z0-9-]*$", "")
68 | }
69 |
70 | func promptSubject() (string, error) {
71 | return promptText("subject", "^[a-z].+$", "")
72 | }
73 |
74 | func promptBody() (string, error) {
75 | return promptText("body (leave empty to finish)", "^.*$", "")
76 | }
77 |
78 | func promptIssueID(issueLabel, issueRegex, defaultValue string) (string, error) {
79 | return promptText(issueLabel, "^("+issueRegex+")?$", defaultValue)
80 | }
81 |
82 | func promptBreakingChanges() (string, error) {
83 | return promptText("Breaking change description", "[a-z].+", "")
84 | }
85 |
86 | func promptSelect(label string, items interface{}, template *promptui.SelectTemplates) (int, error) {
87 | if items == nil || reflect.TypeOf(items).Kind() != reflect.Slice {
88 | return 0, fmt.Errorf("items %v is not a slice", items)
89 | }
90 |
91 | prompt := promptui.Select{
92 | Label: label,
93 | Size: reflect.ValueOf(items).Len(),
94 | Items: items,
95 | Templates: template,
96 | }
97 |
98 | index, _, err := prompt.Run()
99 | return index, err
100 | }
101 |
102 | func promptText(label, regex, defaultValue string) (string, error) {
103 | validate := func(input string) error {
104 | regex := regexp.MustCompile(regex)
105 | if !regex.MatchString(input) {
106 | return fmt.Errorf("invalid value, expected: %s", regex)
107 | }
108 | return nil
109 | }
110 |
111 | prompt := promptui.Prompt{
112 | Label: label,
113 | Default: defaultValue,
114 | Validate: validate,
115 | }
116 |
117 | return prompt.Run()
118 | }
119 |
120 | func promptConfirm(label string) (bool, error) {
121 | r, err := promptText(label+" [y/n]", "^y|n$", "")
122 | if err != nil {
123 | return false, err
124 | }
125 | return r == "y", nil
126 | }
127 |
--------------------------------------------------------------------------------
/sv/semver_test.go:
--------------------------------------------------------------------------------
1 | package sv
2 |
3 | import (
4 | "reflect"
5 | "testing"
6 |
7 | "github.com/Masterminds/semver/v3"
8 | )
9 |
10 | func TestSemVerCommitsProcessorImpl_NextVersion(t *testing.T) {
11 | tests := []struct {
12 | name string
13 | ignoreUnknown bool
14 | version *semver.Version
15 | commits []GitCommitLog
16 | want *semver.Version
17 | wantUpdated bool
18 | }{
19 | {"no update", true, version("0.0.0"), []GitCommitLog{}, version("0.0.0"), false},
20 | {"no update without version", true, nil, []GitCommitLog{}, nil, false},
21 | {"no update on unknown type", true, version("0.0.0"), []GitCommitLog{commitlog("a", map[string]string{}, "a")}, version("0.0.0"), false},
22 | {"no update on unmapped known type", false, version("0.0.0"), []GitCommitLog{commitlog("none", map[string]string{}, "a")}, version("0.0.0"), false},
23 | {"update patch on unknown type", false, version("0.0.0"), []GitCommitLog{commitlog("a", map[string]string{}, "a")}, version("0.0.1"), true},
24 | {"patch update", false, version("0.0.0"), []GitCommitLog{commitlog("patch", map[string]string{}, "a")}, version("0.0.1"), true},
25 | {"patch update without version", false, nil, []GitCommitLog{commitlog("patch", map[string]string{}, "a")}, nil, true},
26 | {"minor update", false, version("0.0.0"), []GitCommitLog{commitlog("patch", map[string]string{}, "a"), commitlog("minor", map[string]string{}, "a")}, version("0.1.0"), true},
27 | {"major update", false, version("0.0.0"), []GitCommitLog{commitlog("patch", map[string]string{}, "a"), commitlog("major", map[string]string{}, "a")}, version("1.0.0"), true},
28 | {"breaking change update", false, version("0.0.0"), []GitCommitLog{commitlog("patch", map[string]string{}, "a"), commitlog("patch", map[string]string{"breaking-change": "break"}, "a")}, version("1.0.0"), true},
29 | }
30 | for _, tt := range tests {
31 | t.Run(tt.name, func(t *testing.T) {
32 | p := NewSemVerCommitsProcessor(VersioningConfig{UpdateMajor: []string{"major"}, UpdateMinor: []string{"minor"}, UpdatePatch: []string{"patch"}, IgnoreUnknown: tt.ignoreUnknown}, CommitMessageConfig{Types: []string{"major", "minor", "patch", "none"}})
33 | got, gotUpdated := p.NextVersion(tt.version, tt.commits)
34 | if !reflect.DeepEqual(got, tt.want) {
35 | t.Errorf("SemVerCommitsProcessorImpl.NextVersion() Version = %v, want %v", got, tt.want)
36 | }
37 | if tt.wantUpdated != gotUpdated {
38 | t.Errorf("SemVerCommitsProcessorImpl.NextVersion() Updated = %v, want %v", gotUpdated, tt.wantUpdated)
39 | }
40 | })
41 | }
42 | }
43 |
44 | func TestToVersion(t *testing.T) {
45 | tests := []struct {
46 | name string
47 | input string
48 | want *semver.Version
49 | wantErr bool
50 | }{
51 | {"empty version", "", version("0.0.0"), false},
52 | {"invalid version", "abc", nil, true},
53 | {"valid version", "1.2.3", version("1.2.3"), false},
54 | }
55 | for _, tt := range tests {
56 | t.Run(tt.name, func(t *testing.T) {
57 | got, err := ToVersion(tt.input)
58 | if (err != nil) != tt.wantErr {
59 | t.Errorf("ToVersion() error = %v, wantErr %v", err, tt.wantErr)
60 | return
61 | }
62 | if !reflect.DeepEqual(got, tt.want) {
63 | t.Errorf("ToVersion() = %v, want %v", got, tt.want)
64 | }
65 | })
66 | }
67 | }
68 |
69 | func TestIsValidVersion(t *testing.T) {
70 | tests := []struct {
71 | name string
72 | value string
73 | want bool
74 | }{
75 | {"simple version", "1.0.0", true},
76 | {"with v prefix version", "v1.0.0", true},
77 | {"prerelease version", "1.0.0-alpha", true},
78 | {"prerelease version", "1.0.0-alpha.1", true},
79 | {"prerelease version", "1.0.0-0.3.7", true},
80 | {"prerelease version", "1.0.0-x.7.z.92", true},
81 | {"prerelease version", "1.0.0-x-y-z.-", true},
82 | {"metadata version", "1.0.0-alpha+001", true},
83 | {"metadata version", "1.0.0+20130313144700", true},
84 | {"metadata version", "1.0.0-beta+exp.sha.5114f85", true},
85 | {"metadata version", "1.0.0+21AF26D3-117B344092BD", true},
86 | {"incomplete version", "1", true},
87 | {"invalid version", "invalid", false},
88 | {"invalid prefix version", "random1.0.0", false},
89 | }
90 | for _, tt := range tests {
91 | t.Run(tt.name, func(t *testing.T) {
92 | if got := IsValidVersion(tt.value); got != tt.want {
93 | t.Errorf("IsValidVersion(%s) = %v, want %v", tt.value, got, tt.want)
94 | }
95 | })
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/sv/formatter_test.go:
--------------------------------------------------------------------------------
1 | package sv
2 |
3 | import (
4 | "bytes"
5 | "os"
6 | "testing"
7 | "time"
8 |
9 | "github.com/Masterminds/semver/v3"
10 | )
11 |
12 | var templatesFS = os.DirFS("../cmd/git-sv/resources/templates")
13 |
14 | var dateChangelog = `## v1.0.0 (2020-05-01)
15 | `
16 |
17 | var nonVersioningChangelog = `## abc (2020-05-01)
18 | `
19 |
20 | var emptyDateChangelog = `## v1.0.0
21 | `
22 |
23 | var emptyVersionChangelog = `## 2020-05-01
24 | `
25 |
26 | var fullChangeLog = `## v1.0.0 (2020-05-01)
27 |
28 | ### Features
29 |
30 | - subject text ()
31 |
32 | ### Bug Fixes
33 |
34 | - subject text ()
35 |
36 | ### Build
37 |
38 | - subject text ()
39 |
40 | ### Breaking Changes
41 |
42 | - break change message
43 | `
44 |
45 | func TestOutputFormatterImpl_FormatReleaseNote(t *testing.T) {
46 | date, _ := time.Parse("2006-01-02", "2020-05-01")
47 |
48 | tests := []struct {
49 | name string
50 | input ReleaseNote
51 | want string
52 | wantErr bool
53 | }{
54 | {"with date", emptyReleaseNote("1.0.0", date.Truncate(time.Minute)), dateChangelog, false},
55 | {"without date", emptyReleaseNote("1.0.0", time.Time{}.Truncate(time.Minute)), emptyDateChangelog, false},
56 | {"without version", emptyReleaseNote("", date.Truncate(time.Minute)), emptyVersionChangelog, false},
57 | {"non versioning tag", emptyReleaseNote("abc", date.Truncate(time.Minute)), nonVersioningChangelog, false},
58 | {"full changelog", fullReleaseNote("1.0.0", date.Truncate(time.Minute)), fullChangeLog, false},
59 | }
60 | for _, tt := range tests {
61 | t.Run(tt.name, func(t *testing.T) {
62 | got, err := NewOutputFormatter(templatesFS).FormatReleaseNote(tt.input)
63 | if got != tt.want {
64 | t.Errorf("OutputFormatterImpl.FormatReleaseNote() = %v, want %v", got, tt.want)
65 | }
66 |
67 | if (err != nil) != tt.wantErr {
68 | t.Errorf("OutputFormatterImpl.FormatReleaseNote() error = %v, wantErr %v", err, tt.wantErr)
69 | }
70 | })
71 | }
72 | }
73 |
74 | func emptyReleaseNote(tag string, date time.Time) ReleaseNote {
75 | v, _ := semver.NewVersion(tag)
76 | return ReleaseNote{
77 | Version: v,
78 | Tag: tag,
79 | Date: date,
80 | }
81 | }
82 |
83 | func fullReleaseNote(tag string, date time.Time) ReleaseNote {
84 | v, _ := semver.NewVersion(tag)
85 | sections := []ReleaseNoteSection{
86 | newReleaseNoteCommitsSection("Features", []string{"feat"}, []GitCommitLog{commitlog("feat", map[string]string{}, "a")}),
87 | newReleaseNoteCommitsSection("Bug Fixes", []string{"fix"}, []GitCommitLog{commitlog("fix", map[string]string{}, "a")}),
88 | newReleaseNoteCommitsSection("Build", []string{"build"}, []GitCommitLog{commitlog("build", map[string]string{}, "a")}),
89 | ReleaseNoteBreakingChangeSection{"Breaking Changes", []string{"break change message"}},
90 | }
91 | return releaseNote(v, tag, date, sections, map[string]struct{}{"a": {}})
92 | }
93 |
94 | func Test_checkTemplatesExecution(t *testing.T) {
95 | tpls := NewOutputFormatter(templatesFS).templates
96 | tests := []struct {
97 | template string
98 | variables interface{}
99 | }{
100 | {"changelog-md.tpl", changelogVariables("v1.0.0", "v1.0.1")},
101 | {"releasenotes-md.tpl", releaseNotesVariables("v1.0.0")},
102 | }
103 | for _, tt := range tests {
104 | t.Run(tt.template, func(t *testing.T) {
105 | var b bytes.Buffer
106 | err := tpls.ExecuteTemplate(&b, tt.template, tt.variables)
107 | if err != nil {
108 | t.Errorf("invalid template err = %v", err)
109 | return
110 | }
111 | if len(b.Bytes()) <= 0 {
112 | t.Errorf("empty template")
113 | }
114 | })
115 | }
116 | }
117 |
118 | func releaseNotesVariables(release string) releaseNoteTemplateVariables {
119 | return releaseNoteTemplateVariables{
120 | Release: release,
121 | Date: time.Date(2006, 1, 02, 0, 0, 0, 0, time.UTC),
122 | Sections: []ReleaseNoteSection{
123 | newReleaseNoteCommitsSection("Features", []string{"feat"}, []GitCommitLog{commitlog("feat", map[string]string{}, "a")}),
124 | newReleaseNoteCommitsSection("Bug Fixes", []string{"fix"}, []GitCommitLog{commitlog("fix", map[string]string{}, "a")}),
125 | newReleaseNoteCommitsSection("Build", []string{"build"}, []GitCommitLog{commitlog("build", map[string]string{}, "a")}),
126 | ReleaseNoteBreakingChangeSection{"Breaking Changes", []string{"break change message"}},
127 | },
128 | }
129 | }
130 |
131 | func changelogVariables(releases ...string) []releaseNoteTemplateVariables {
132 | var variables []releaseNoteTemplateVariables
133 | for _, r := range releases {
134 | variables = append(variables, releaseNotesVariables(r))
135 | }
136 | return variables
137 |
138 | }
139 |
--------------------------------------------------------------------------------
/sv/releasenotes.go:
--------------------------------------------------------------------------------
1 | package sv
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/Masterminds/semver/v3"
7 | )
8 |
9 | // ReleaseNoteProcessor release note processor interface.
10 | type ReleaseNoteProcessor interface {
11 | Create(version *semver.Version, tag string, date time.Time, commits []GitCommitLog) ReleaseNote
12 | }
13 |
14 | // ReleaseNoteProcessorImpl release note based on commit log.
15 | type ReleaseNoteProcessorImpl struct {
16 | cfg ReleaseNotesConfig
17 | }
18 |
19 | // NewReleaseNoteProcessor ReleaseNoteProcessor constructor.
20 | func NewReleaseNoteProcessor(cfg ReleaseNotesConfig) *ReleaseNoteProcessorImpl {
21 | return &ReleaseNoteProcessorImpl{cfg: cfg}
22 | }
23 |
24 | // Create create a release note based on commits.
25 | func (p ReleaseNoteProcessorImpl) Create(version *semver.Version, tag string, date time.Time, commits []GitCommitLog) ReleaseNote {
26 | mapping := commitSectionMapping(p.cfg.Sections)
27 |
28 | sections := make(map[string]ReleaseNoteCommitsSection)
29 | authors := make(map[string]struct{})
30 | var breakingChanges []string
31 | for _, commit := range commits {
32 | authors[commit.AuthorName] = struct{}{}
33 | if sectionCfg, exists := mapping[commit.Message.Type]; exists {
34 | section, sexists := sections[sectionCfg.Name]
35 | if !sexists {
36 | section = ReleaseNoteCommitsSection{Name: sectionCfg.Name, Types: sectionCfg.CommitTypes}
37 | }
38 | section.Items = append(section.Items, commit)
39 | sections[sectionCfg.Name] = section
40 | }
41 | if commit.Message.BreakingMessage() != "" {
42 | // TODO: if no message found, should use description instead?
43 | breakingChanges = append(breakingChanges, commit.Message.BreakingMessage())
44 | }
45 | }
46 |
47 | var breakingChangeSection ReleaseNoteBreakingChangeSection
48 | if bcCfg := p.cfg.sectionConfig(ReleaseNotesSectionTypeBreakingChanges); bcCfg != nil && len(breakingChanges) > 0 {
49 | breakingChangeSection = ReleaseNoteBreakingChangeSection{Name: bcCfg.Name, Messages: breakingChanges}
50 | }
51 | return ReleaseNote{Version: version, Tag: tag, Date: date.Truncate(time.Minute), Sections: p.toReleaseNoteSections(sections, breakingChangeSection), AuthorsNames: authors}
52 | }
53 |
54 | func (p ReleaseNoteProcessorImpl) toReleaseNoteSections(commitSections map[string]ReleaseNoteCommitsSection, breakingChange ReleaseNoteBreakingChangeSection) []ReleaseNoteSection {
55 | hasBreaking := 0
56 | if breakingChange.Name != "" {
57 | hasBreaking = 1
58 | }
59 |
60 | sections := make([]ReleaseNoteSection, len(commitSections)+hasBreaking)
61 | i := 0
62 | for _, cfg := range p.cfg.Sections {
63 | if cfg.SectionType == ReleaseNotesSectionTypeBreakingChanges && hasBreaking > 0 {
64 | sections[i] = breakingChange
65 | i++
66 | }
67 | if s, exists := commitSections[cfg.Name]; cfg.SectionType == ReleaseNotesSectionTypeCommits && exists {
68 | sections[i] = s
69 | i++
70 | }
71 | }
72 |
73 | return sections
74 | }
75 |
76 | func commitSectionMapping(sections []ReleaseNotesSectionConfig) map[string]ReleaseNotesSectionConfig {
77 | mapping := make(map[string]ReleaseNotesSectionConfig)
78 | for _, section := range sections {
79 | if section.SectionType == ReleaseNotesSectionTypeCommits {
80 | for _, commitType := range section.CommitTypes {
81 | mapping[commitType] = section
82 | }
83 | }
84 | }
85 | return mapping
86 | }
87 |
88 | // ReleaseNote release note.
89 | type ReleaseNote struct {
90 | Version *semver.Version
91 | Tag string
92 | Date time.Time
93 | Sections []ReleaseNoteSection
94 | AuthorsNames map[string]struct{}
95 | }
96 |
97 | // ReleaseNoteSection section in release notes.
98 | type ReleaseNoteSection interface {
99 | SectionType() string
100 | SectionName() string
101 | }
102 |
103 | // ReleaseNoteBreakingChangeSection breaking change section.
104 | type ReleaseNoteBreakingChangeSection struct {
105 | Name string
106 | Messages []string
107 | }
108 |
109 | // SectionType section type.
110 | func (ReleaseNoteBreakingChangeSection) SectionType() string {
111 | return ReleaseNotesSectionTypeBreakingChanges
112 | }
113 |
114 | // SectionName section name.
115 | func (s ReleaseNoteBreakingChangeSection) SectionName() string {
116 | return s.Name
117 | }
118 |
119 | // ReleaseNoteCommitsSection release note section.
120 | type ReleaseNoteCommitsSection struct {
121 | Name string
122 | Types []string
123 | Items []GitCommitLog
124 | }
125 |
126 | // SectionType section type.
127 | func (ReleaseNoteCommitsSection) SectionType() string {
128 | return ReleaseNotesSectionTypeCommits
129 | }
130 |
131 | // SectionName section name.
132 | func (s ReleaseNoteCommitsSection) SectionName() string {
133 | return s.Name
134 | }
135 |
136 | // HasMultipleTypes return true if has more than one commit type.
137 | func (s ReleaseNoteCommitsSection) HasMultipleTypes() bool {
138 | return len(s.Types) > 1
139 | }
140 |
--------------------------------------------------------------------------------
/cmd/git-sv/config.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "os"
7 | "os/exec"
8 | "reflect"
9 | "strings"
10 |
11 | "github.com/bvieira/sv4git/v2/sv"
12 | "github.com/imdario/mergo"
13 | "github.com/kelseyhightower/envconfig"
14 | "gopkg.in/yaml.v3"
15 | )
16 |
17 | // EnvConfig env vars for cli configuration.
18 | type EnvConfig struct {
19 | Home string `envconfig:"SV4GIT_HOME" default:""`
20 | }
21 |
22 | func loadEnvConfig() EnvConfig {
23 | var c EnvConfig
24 | err := envconfig.Process("", &c)
25 | if err != nil {
26 | log.Fatal("failed to load env config, error: ", err.Error())
27 | }
28 | return c
29 | }
30 |
31 | // Config cli yaml config.
32 | type Config struct {
33 | Version string `yaml:"version"`
34 | Versioning sv.VersioningConfig `yaml:"versioning"`
35 | Tag sv.TagConfig `yaml:"tag"`
36 | ReleaseNotes sv.ReleaseNotesConfig `yaml:"release-notes"`
37 | Branches sv.BranchesConfig `yaml:"branches"`
38 | CommitMessage sv.CommitMessageConfig `yaml:"commit-message"`
39 | }
40 |
41 | func getRepoPath() (string, error) {
42 | cmd := exec.Command("git", "rev-parse", "--show-toplevel")
43 | out, err := cmd.CombinedOutput()
44 | if err != nil {
45 | return "", combinedOutputErr(err, out)
46 | }
47 | return strings.TrimSpace(string(out)), nil
48 | }
49 |
50 | func combinedOutputErr(err error, out []byte) error {
51 | msg := strings.Split(string(out), "\n")
52 | return fmt.Errorf("%v - %s", err, msg[0])
53 | }
54 |
55 | func readConfig(filepath string) (Config, error) {
56 | content, rerr := os.ReadFile(filepath)
57 | if rerr != nil {
58 | return Config{}, rerr
59 | }
60 |
61 | var cfg Config
62 | cerr := yaml.Unmarshal(content, &cfg)
63 | if cerr != nil {
64 | return Config{}, fmt.Errorf("could not parse config from path: %s, error: %v", filepath, cerr)
65 | }
66 |
67 | return cfg, nil
68 | }
69 |
70 | func defaultConfig() Config {
71 | skipDetached := false
72 | pattern := "%d.%d.%d"
73 | filter := ""
74 | return Config{
75 | Version: "1.1",
76 | Versioning: sv.VersioningConfig{
77 | UpdateMajor: []string{},
78 | UpdateMinor: []string{"feat"},
79 | UpdatePatch: []string{"build", "ci", "chore", "docs", "fix", "perf", "refactor", "style", "test"},
80 | IgnoreUnknown: false,
81 | },
82 | Tag: sv.TagConfig{
83 | Pattern: &pattern,
84 | Filter: &filter,
85 | },
86 | ReleaseNotes: sv.ReleaseNotesConfig{
87 | Sections: []sv.ReleaseNotesSectionConfig{
88 | {Name: "Features", SectionType: sv.ReleaseNotesSectionTypeCommits, CommitTypes: []string{"feat"}},
89 | {Name: "Bug Fixes", SectionType: sv.ReleaseNotesSectionTypeCommits, CommitTypes: []string{"fix"}},
90 | {Name: "Breaking Changes", SectionType: sv.ReleaseNotesSectionTypeBreakingChanges},
91 | },
92 | },
93 | Branches: sv.BranchesConfig{
94 | Prefix: "([a-z]+\\/)?",
95 | Suffix: "(-.*)?",
96 | DisableIssue: false,
97 | Skip: []string{"master", "main", "developer"},
98 | SkipDetached: &skipDetached,
99 | },
100 | CommitMessage: sv.CommitMessageConfig{
101 | Types: []string{"build", "ci", "chore", "docs", "feat", "fix", "perf", "refactor", "revert", "style", "test"},
102 | Scope: sv.CommitMessageScopeConfig{},
103 | Footer: map[string]sv.CommitMessageFooterConfig{
104 | "issue": {Key: "jira", KeySynonyms: []string{"Jira", "JIRA"}},
105 | },
106 | Issue: sv.CommitMessageIssueConfig{Regex: "[A-Z]+-[0-9]+"},
107 | HeaderSelector: "",
108 | },
109 | }
110 | }
111 |
112 | func merge(dst *Config, src Config) error {
113 | err := mergo.Merge(dst, src, mergo.WithOverride, mergo.WithTransformers(&mergeTransformer{}))
114 | if err == nil {
115 | if len(src.ReleaseNotes.Headers) > 0 { // mergo is merging maps, ReleaseNotes.Headers should be overwritten
116 | dst.ReleaseNotes.Headers = src.ReleaseNotes.Headers
117 | }
118 | }
119 | return err
120 | }
121 |
122 | type mergeTransformer struct{}
123 |
124 | func (t *mergeTransformer) Transformer(typ reflect.Type) func(dst, src reflect.Value) error {
125 | if typ.Kind() == reflect.Slice {
126 | return func(dst, src reflect.Value) error {
127 | if dst.CanSet() && !src.IsNil() {
128 | dst.Set(src)
129 | }
130 | return nil
131 | }
132 | }
133 |
134 | if typ.Kind() == reflect.Ptr {
135 | return func(dst, src reflect.Value) error {
136 | if dst.CanSet() && !src.IsNil() {
137 | dst.Set(src)
138 | }
139 | return nil
140 | }
141 | }
142 | return nil
143 | }
144 |
145 | func migrateConfig(cfg Config, filename string) Config {
146 | if cfg.ReleaseNotes.Headers == nil {
147 | return cfg
148 | }
149 | warnf("config 'release-notes.headers' on %s is deprecated, please use 'sections' instead!", filename)
150 |
151 | return Config{
152 | Version: cfg.Version,
153 | Versioning: cfg.Versioning,
154 | Tag: cfg.Tag,
155 | ReleaseNotes: sv.ReleaseNotesConfig{
156 | Sections: migrateReleaseNotesConfig(cfg.ReleaseNotes.Headers),
157 | },
158 | Branches: cfg.Branches,
159 | CommitMessage: cfg.CommitMessage,
160 | }
161 | }
162 |
163 | func migrateReleaseNotesConfig(headers map[string]string) []sv.ReleaseNotesSectionConfig {
164 | order := []string{"feat", "fix", "refactor", "perf", "test", "build", "ci", "chore", "docs", "style"}
165 | var sections []sv.ReleaseNotesSectionConfig
166 | for _, key := range order {
167 | if name, exists := headers[key]; exists {
168 | sections = append(sections, sv.ReleaseNotesSectionConfig{Name: name, SectionType: sv.ReleaseNotesSectionTypeCommits, CommitTypes: []string{key}})
169 | }
170 | }
171 | if name, exists := headers["breaking-change"]; exists {
172 | sections = append(sections, sv.ReleaseNotesSectionConfig{Name: name, SectionType: sv.ReleaseNotesSectionTypeBreakingChanges})
173 | }
174 | return sections
175 | }
176 |
--------------------------------------------------------------------------------
/sv/git.go:
--------------------------------------------------------------------------------
1 | package sv
2 |
3 | import (
4 | "bufio"
5 | "bytes"
6 | "errors"
7 | "fmt"
8 | "os"
9 | "os/exec"
10 | "strconv"
11 | "strings"
12 | "time"
13 |
14 | "github.com/Masterminds/semver/v3"
15 | )
16 |
17 | const (
18 | logSeparator = "###"
19 | endLine = "~~~"
20 | )
21 |
22 | // Git commands.
23 | type Git interface {
24 | LastTag() string
25 | Log(lr LogRange) ([]GitCommitLog, error)
26 | Commit(header, body, footer string) error
27 | Tag(version semver.Version) (string, error)
28 | Tags() ([]GitTag, error)
29 | Branch() string
30 | IsDetached() (bool, error)
31 | }
32 |
33 | // GitCommitLog description of a single commit log.
34 | type GitCommitLog struct {
35 | Date string `json:"date,omitempty"`
36 | Timestamp int `json:"timestamp,omitempty"`
37 | AuthorName string `json:"authorName,omitempty"`
38 | Hash string `json:"hash,omitempty"`
39 | Message CommitMessage `json:"message,omitempty"`
40 | }
41 |
42 | // GitTag git tag info.
43 | type GitTag struct {
44 | Name string
45 | Date time.Time
46 | }
47 |
48 | // LogRangeType type of log range.
49 | type LogRangeType string
50 |
51 | // constants for log range type.
52 | const (
53 | TagRange LogRangeType = "tag"
54 | DateRange LogRangeType = "date"
55 | HashRange LogRangeType = "hash"
56 | )
57 |
58 | // LogRange git log range.
59 | type LogRange struct {
60 | rangeType LogRangeType
61 | start string
62 | end string
63 | }
64 |
65 | // NewLogRange LogRange constructor.
66 | func NewLogRange(t LogRangeType, start, end string) LogRange {
67 | return LogRange{rangeType: t, start: start, end: end}
68 | }
69 |
70 | // GitImpl git command implementation.
71 | type GitImpl struct {
72 | messageProcessor MessageProcessor
73 | tagCfg TagConfig
74 | }
75 |
76 | // NewGit constructor.
77 | func NewGit(messageProcessor MessageProcessor, cfg TagConfig) *GitImpl {
78 | return &GitImpl{
79 | messageProcessor: messageProcessor,
80 | tagCfg: cfg,
81 | }
82 | }
83 |
84 | // LastTag get last tag, if no tag found, return empty.
85 | func (g GitImpl) LastTag() string {
86 | cmd := exec.Command("git", "for-each-ref", "refs/tags/"+*g.tagCfg.Filter, "--sort", "-creatordate", "--format", "%(refname:short)", "--count", "1")
87 | out, err := cmd.CombinedOutput()
88 | if err != nil {
89 | return ""
90 | }
91 | return strings.TrimSpace(strings.Trim(string(out), "\n"))
92 | }
93 |
94 | // Log return git log.
95 | func (g GitImpl) Log(lr LogRange) ([]GitCommitLog, error) {
96 | format := "--pretty=format:\"%ad" + logSeparator + "%at" + logSeparator + "%cN" + logSeparator + "%h" + logSeparator + "%s" + logSeparator + "%b" + endLine + "\""
97 | params := []string{"log", "--date=short", format}
98 |
99 | if lr.start != "" || lr.end != "" {
100 | switch lr.rangeType {
101 | case DateRange:
102 | params = append(params, "--since", lr.start, "--until", addDay(lr.end))
103 | default:
104 | if lr.start == "" {
105 | params = append(params, lr.end)
106 | } else {
107 | params = append(params, lr.start+".."+str(lr.end, "HEAD"))
108 | }
109 | }
110 | }
111 |
112 | cmd := exec.Command("git", params...)
113 | out, err := cmd.CombinedOutput()
114 | if err != nil {
115 | return nil, combinedOutputErr(err, out)
116 | }
117 | logs, parseErr := parseLogOutput(g.messageProcessor, string(out))
118 | if parseErr != nil {
119 | return nil, parseErr
120 | }
121 | return logs, nil
122 | }
123 |
124 | // Commit runs git commit.
125 | func (g GitImpl) Commit(header, body, footer string) error {
126 | cmd := exec.Command("git", "commit", "-m", header, "-m", "", "-m", body, "-m", "", "-m", footer)
127 | cmd.Stdout = os.Stdout
128 | cmd.Stderr = os.Stderr
129 | return cmd.Run()
130 | }
131 |
132 | // Tag create a git tag.
133 | func (g GitImpl) Tag(version semver.Version) (string, error) {
134 | tag := fmt.Sprintf(*g.tagCfg.Pattern, version.Major(), version.Minor(), version.Patch())
135 | tagMsg := fmt.Sprintf("Version %d.%d.%d", version.Major(), version.Minor(), version.Patch())
136 |
137 | tagCommand := exec.Command("git", "tag", "-a", tag, "-m", tagMsg)
138 | if out, err := tagCommand.CombinedOutput(); err != nil {
139 | return tag, combinedOutputErr(err, out)
140 | }
141 |
142 | pushCommand := exec.Command("git", "push", "origin", tag)
143 | if out, err := pushCommand.CombinedOutput(); err != nil {
144 | return tag, combinedOutputErr(err, out)
145 | }
146 | return tag, nil
147 | }
148 |
149 | // Tags list repository tags.
150 | func (g GitImpl) Tags() ([]GitTag, error) {
151 | cmd := exec.Command("git", "for-each-ref", "--sort", "creatordate", "--format", "%(creatordate:iso8601)#%(refname:short)", "refs/tags/"+*g.tagCfg.Filter)
152 | out, err := cmd.CombinedOutput()
153 | if err != nil {
154 | return nil, combinedOutputErr(err, out)
155 | }
156 | return parseTagsOutput(string(out))
157 | }
158 |
159 | // Branch get git branch.
160 | func (GitImpl) Branch() string {
161 | cmd := exec.Command("git", "symbolic-ref", "--short", "HEAD")
162 | out, err := cmd.CombinedOutput()
163 | if err != nil {
164 | return ""
165 | }
166 | return strings.TrimSpace(strings.Trim(string(out), "\n"))
167 | }
168 |
169 | // IsDetached check if is detached.
170 | func (GitImpl) IsDetached() (bool, error) {
171 | cmd := exec.Command("git", "symbolic-ref", "-q", "HEAD")
172 | out, err := cmd.CombinedOutput()
173 | if output := string(out); err != nil { //-q: do not issue an error message if the is not a symbolic ref, but a detached HEAD; instead exit with non-zero status silently.
174 | if output == "" {
175 | return true, nil
176 | }
177 | return false, errors.New(output)
178 | }
179 | return false, nil
180 | }
181 |
182 | func parseTagsOutput(input string) ([]GitTag, error) {
183 | scanner := bufio.NewScanner(strings.NewReader(input))
184 | var result []GitTag
185 | for scanner.Scan() {
186 | if line := strings.TrimSpace(scanner.Text()); line != "" {
187 | values := strings.Split(line, "#")
188 | date, _ := time.Parse("2006-01-02 15:04:05 -0700", values[0]) // ignore invalid dates
189 | result = append(result, GitTag{Name: values[1], Date: date})
190 | }
191 | }
192 | return result, nil
193 | }
194 |
195 | func parseLogOutput(messageProcessor MessageProcessor, log string) ([]GitCommitLog, error) {
196 | scanner := bufio.NewScanner(strings.NewReader(log))
197 | scanner.Split(splitAt([]byte(endLine)))
198 | var logs []GitCommitLog
199 | for scanner.Scan() {
200 | if text := strings.TrimSpace(strings.Trim(scanner.Text(), "\"")); text != "" {
201 | log, err := parseCommitLog(messageProcessor, text)
202 | if err != nil {
203 | return nil, err
204 | }
205 | logs = append(logs, log)
206 | }
207 | }
208 | return logs, nil
209 | }
210 |
211 | func parseCommitLog(messageProcessor MessageProcessor, commit string) (GitCommitLog, error) {
212 | content := strings.Split(strings.Trim(commit, "\""), logSeparator)
213 |
214 | timestamp, _ := strconv.Atoi(content[1])
215 | message, err := messageProcessor.Parse(content[4], content[5])
216 |
217 | if err != nil {
218 | return GitCommitLog{}, err
219 | }
220 |
221 | return GitCommitLog{
222 | Date: content[0],
223 | Timestamp: timestamp,
224 | AuthorName: content[2],
225 | Hash: content[3],
226 | Message: message,
227 | }, nil
228 | }
229 |
230 | func splitAt(b []byte) func(data []byte, atEOF bool) (advance int, token []byte, err error) {
231 | return func(data []byte, atEOF bool) (advance int, token []byte, err error) {
232 | dataLen := len(data)
233 |
234 | if atEOF && dataLen == 0 {
235 | return 0, nil, nil
236 | }
237 |
238 | if i := bytes.Index(data, b); i >= 0 {
239 | return i + len(b), data[0:i], nil
240 | }
241 |
242 | if atEOF {
243 | return dataLen, data, nil
244 | }
245 |
246 | return 0, nil, nil
247 | }
248 | }
249 |
250 | func addDay(value string) string {
251 | if value == "" {
252 | return value
253 | }
254 |
255 | t, err := time.Parse("2006-01-02", value)
256 | if err != nil { // keep original value if is not date format
257 | return value
258 | }
259 |
260 | return t.AddDate(0, 0, 1).Format("2006-01-02")
261 | }
262 |
263 | func str(value, defaultValue string) string {
264 | if value != "" {
265 | return value
266 | }
267 | return defaultValue
268 | }
269 |
270 | func combinedOutputErr(err error, out []byte) error {
271 | msg := strings.Split(string(out), "\n")
272 | return fmt.Errorf("%v - %s", err, msg[0])
273 | }
274 |
--------------------------------------------------------------------------------
/cmd/git-sv/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "embed"
5 | "io/fs"
6 | "log"
7 | "os"
8 | "path/filepath"
9 |
10 | "github.com/bvieira/sv4git/v2/sv"
11 | "github.com/urfave/cli/v2"
12 | )
13 |
14 | // Version for git-sv.
15 | var Version = "source"
16 |
17 | const (
18 | configFilename = "config.yml"
19 | repoConfigFilename = ".sv4git.yml"
20 | configDir = ".sv4git"
21 | )
22 |
23 | var (
24 | //go:embed resources/templates/*.tpl
25 | defaultTemplatesFS embed.FS
26 | )
27 |
28 | func templateFS(filepath string) fs.FS {
29 | if _, err := os.Stat(filepath); err != nil {
30 | defaultTemplatesFS, _ := fs.Sub(defaultTemplatesFS, "resources/templates")
31 | return defaultTemplatesFS
32 | }
33 | return os.DirFS(filepath)
34 | }
35 |
36 | func main() {
37 | log.SetFlags(0)
38 |
39 | repoPath, rerr := getRepoPath()
40 | if rerr != nil {
41 | log.Fatal("failed to discovery repository top level, error: ", rerr)
42 | }
43 |
44 | cfg := loadCfg(repoPath)
45 | messageProcessor := sv.NewMessageProcessor(cfg.CommitMessage, cfg.Branches)
46 | git := sv.NewGit(messageProcessor, cfg.Tag)
47 | semverProcessor := sv.NewSemVerCommitsProcessor(cfg.Versioning, cfg.CommitMessage)
48 | releasenotesProcessor := sv.NewReleaseNoteProcessor(cfg.ReleaseNotes)
49 | outputFormatter := sv.NewOutputFormatter(templateFS(filepath.Join(repoPath, configDir, "templates")))
50 |
51 | app := cli.NewApp()
52 | app.Name = "sv"
53 | app.Version = Version
54 | app.Usage = "semantic version for git"
55 | app.Commands = []*cli.Command{
56 | {
57 | Name: "config",
58 | Aliases: []string{"cfg"},
59 | Usage: "cli configuration",
60 | Subcommands: []*cli.Command{
61 | {
62 | Name: "default",
63 | Usage: "show default config",
64 | Action: configDefaultHandler(),
65 | },
66 | {
67 | Name: "show",
68 | Usage: "show current config",
69 | Action: configShowHandler(cfg),
70 | },
71 | },
72 | },
73 | {
74 | Name: "current-version",
75 | Aliases: []string{"cv"},
76 | Usage: "get last released version from git",
77 | Action: currentVersionHandler(git),
78 | },
79 | {
80 | Name: "next-version",
81 | Aliases: []string{"nv"},
82 | Usage: "generate the next version based on git commit messages",
83 | Action: nextVersionHandler(git, semverProcessor),
84 | },
85 | {
86 | Name: "commit-log",
87 | Aliases: []string{"cl"},
88 | Usage: "list all commit logs according to range as jsons",
89 | Description: "The range filter is used based on git log filters, check https://git-scm.com/docs/git-log for more info. When flag range is \"tag\" and start is empty, last tag created will be used instead. When flag range is \"date\", if \"end\" is YYYY-MM-DD the range will be inclusive.",
90 | Action: commitLogHandler(git),
91 | Flags: []cli.Flag{
92 | &cli.StringFlag{Name: "t", Aliases: []string{"tag"}, Usage: "get commit log from a specific tag"},
93 | &cli.StringFlag{Name: "r", Aliases: []string{"range"}, Usage: "type of range of commits, use: tag, date or hash", Value: string(sv.TagRange)},
94 | &cli.StringFlag{Name: "s", Aliases: []string{"start"}, Usage: "start range of git log revision range, if date, the value is used on since flag instead"},
95 | &cli.StringFlag{Name: "e", Aliases: []string{"end"}, Usage: "end range of git log revision range, if date, the value is used on until flag instead"},
96 | },
97 | },
98 | {
99 | Name: "commit-notes",
100 | Aliases: []string{"cn"},
101 | Usage: "generate a commit notes according to range",
102 | Description: "The range filter is used based on git log filters, check https://git-scm.com/docs/git-log for more info. When flag range is \"tag\" and start is empty, last tag created will be used instead. When flag range is \"date\", if \"end\" is YYYY-MM-DD the range will be inclusive.",
103 | Action: commitNotesHandler(git, releasenotesProcessor, outputFormatter),
104 | Flags: []cli.Flag{
105 | &cli.StringFlag{Name: "r", Aliases: []string{"range"}, Usage: "type of range of commits, use: tag, date or hash", Required: true},
106 | &cli.StringFlag{Name: "s", Aliases: []string{"start"}, Usage: "start range of git log revision range, if date, the value is used on since flag instead"},
107 | &cli.StringFlag{Name: "e", Aliases: []string{"end"}, Usage: "end range of git log revision range, if date, the value is used on until flag instead"},
108 | },
109 | },
110 | {
111 | Name: "release-notes",
112 | Aliases: []string{"rn"},
113 | Usage: "generate release notes",
114 | Action: releaseNotesHandler(git, semverProcessor, releasenotesProcessor, outputFormatter),
115 | Flags: []cli.Flag{&cli.StringFlag{Name: "t", Aliases: []string{"tag"}, Usage: "get release note from tag"}},
116 | },
117 | {
118 | Name: "changelog",
119 | Aliases: []string{"cgl"},
120 | Usage: "generate changelog",
121 | Action: changelogHandler(git, semverProcessor, releasenotesProcessor, outputFormatter),
122 | Flags: []cli.Flag{
123 | &cli.IntFlag{Name: "size", Value: 10, Aliases: []string{"n"}, Usage: "get changelog from last 'n' tags"},
124 | &cli.BoolFlag{Name: "all", Usage: "ignore size parameter, get changelog for every tag"},
125 | &cli.BoolFlag{Name: "add-next-version", Usage: "add next version on change log (commits since last tag, but only if there is a new version to release)"},
126 | &cli.BoolFlag{Name: "semantic-version-only", Usage: "only show tags 'SemVer-ish'"},
127 | },
128 | },
129 | {
130 | Name: "tag",
131 | Aliases: []string{"tg"},
132 | Usage: "generate tag with version based on git commit messages",
133 | Action: tagHandler(git, semverProcessor),
134 | },
135 | {
136 | Name: "commit",
137 | Aliases: []string{"cmt"},
138 | Usage: "execute git commit with convetional commit message helper",
139 | Action: commitHandler(cfg, git, messageProcessor),
140 | Flags: []cli.Flag{
141 | &cli.BoolFlag{Name: "no-scope", Aliases: []string{"nsc"}, Usage: "do not prompt for commit scope"},
142 | &cli.BoolFlag{Name: "no-body", Aliases: []string{"nbd"}, Usage: "do not prompt for commit body"},
143 | &cli.BoolFlag{Name: "no-issue", Aliases: []string{"nis"}, Usage: "do not prompt for commit issue, will try to recover from branch if enabled"},
144 | &cli.BoolFlag{Name: "no-breaking", Aliases: []string{"nbc"}, Usage: "do not prompt for breaking changes"},
145 | &cli.StringFlag{Name: "type", Aliases: []string{"t"}, Usage: "define commit type"},
146 | &cli.StringFlag{Name: "scope", Aliases: []string{"s"}, Usage: "define commit scope"},
147 | &cli.StringFlag{Name: "description", Aliases: []string{"d"}, Usage: "define commit description"},
148 | &cli.StringFlag{Name: "breaking-change", Aliases: []string{"b"}, Usage: "define commit breaking change message"},
149 | },
150 | },
151 | {
152 | Name: "validate-commit-message",
153 | Aliases: []string{"vcm"},
154 | Usage: "use as prepare-commit-message hook to validate and enhance commit message",
155 | Action: validateCommitMessageHandler(git, messageProcessor),
156 | Flags: []cli.Flag{
157 | &cli.StringFlag{Name: "path", Required: true, Usage: "git working directory"},
158 | &cli.StringFlag{Name: "file", Required: true, Usage: "name of the file that contains the commit log message"},
159 | &cli.StringFlag{Name: "source", Required: true, Usage: "source of the commit message"},
160 | },
161 | },
162 | }
163 |
164 | if apperr := app.Run(os.Args); apperr != nil {
165 | log.Fatal("ERROR: ", apperr)
166 | }
167 | }
168 |
169 | func loadCfg(repoPath string) Config {
170 | cfg := defaultConfig()
171 |
172 | envCfg := loadEnvConfig()
173 | if envCfg.Home != "" {
174 | homeCfgFilepath := filepath.Join(envCfg.Home, configFilename)
175 | if homeCfg, err := readConfig(homeCfgFilepath); err == nil {
176 | if merr := merge(&cfg, migrateConfig(homeCfg, homeCfgFilepath)); merr != nil {
177 | log.Fatal("failed to merge user config, error: ", merr)
178 | }
179 | }
180 | }
181 |
182 | repoCfgFilepath := filepath.Join(repoPath, repoConfigFilename)
183 | if repoCfg, err := readConfig(repoCfgFilepath); err == nil {
184 | if merr := merge(&cfg, migrateConfig(repoCfg, repoCfgFilepath)); merr != nil {
185 | log.Fatal("failed to merge repo config, error: ", merr)
186 | }
187 | if len(repoCfg.ReleaseNotes.Headers) > 0 { // mergo is merging maps, headers will be overwritten
188 | cfg.ReleaseNotes.Headers = repoCfg.ReleaseNotes.Headers
189 | }
190 | }
191 |
192 | return cfg
193 | }
194 |
--------------------------------------------------------------------------------
/sv/message.go:
--------------------------------------------------------------------------------
1 | package sv
2 |
3 | import (
4 | "bufio"
5 | "fmt"
6 | "regexp"
7 | "strings"
8 | )
9 |
10 | const (
11 | breakingChangeFooterKey = "BREAKING CHANGE"
12 | breakingChangeMetadataKey = "breaking-change"
13 | issueMetadataKey = "issue"
14 | messageRegexGroupName = "header"
15 | )
16 |
17 | // CommitMessage is a message using conventional commits.
18 | type CommitMessage struct {
19 | Type string `json:"type,omitempty"`
20 | Scope string `json:"scope,omitempty"`
21 | Description string `json:"description,omitempty"`
22 | Body string `json:"body,omitempty"`
23 | IsBreakingChange bool `json:"isBreakingChange,omitempty"`
24 | Metadata map[string]string `json:"metadata,omitempty"`
25 | }
26 |
27 | // NewCommitMessage commit message constructor.
28 | func NewCommitMessage(ctype, scope, description, body, issue, breakingChanges string) CommitMessage {
29 | metadata := make(map[string]string)
30 | if issue != "" {
31 | metadata[issueMetadataKey] = issue
32 | }
33 | if breakingChanges != "" {
34 | metadata[breakingChangeMetadataKey] = breakingChanges
35 | }
36 | return CommitMessage{Type: ctype, Scope: scope, Description: description, Body: body, IsBreakingChange: breakingChanges != "", Metadata: metadata}
37 | }
38 |
39 | // Issue return issue from metadata.
40 | func (m CommitMessage) Issue() string {
41 | return m.Metadata[issueMetadataKey]
42 | }
43 |
44 | // BreakingMessage return breaking change message from metadata.
45 | func (m CommitMessage) BreakingMessage() string {
46 | return m.Metadata[breakingChangeMetadataKey]
47 | }
48 |
49 | // MessageProcessor interface.
50 | type MessageProcessor interface {
51 | SkipBranch(branch string, detached bool) bool
52 | Validate(message string) error
53 | ValidateType(ctype string) error
54 | ValidateScope(scope string) error
55 | ValidateDescription(description string) error
56 | Enhance(branch string, message string) (string, error)
57 | IssueID(branch string) (string, error)
58 | Format(msg CommitMessage) (string, string, string)
59 | Parse(subject, body string) (CommitMessage, error)
60 | }
61 |
62 | // NewMessageProcessor MessageProcessorImpl constructor.
63 | func NewMessageProcessor(mcfg CommitMessageConfig, bcfg BranchesConfig) *MessageProcessorImpl {
64 | return &MessageProcessorImpl{
65 | messageCfg: mcfg,
66 | branchesCfg: bcfg,
67 | }
68 | }
69 |
70 | // MessageProcessorImpl process validate message hook.
71 | type MessageProcessorImpl struct {
72 | messageCfg CommitMessageConfig
73 | branchesCfg BranchesConfig
74 | }
75 |
76 | // SkipBranch check if branch should be ignored.
77 | func (p MessageProcessorImpl) SkipBranch(branch string, detached bool) bool {
78 | return contains(branch, p.branchesCfg.Skip) || (p.branchesCfg.SkipDetached != nil && *p.branchesCfg.SkipDetached && detached)
79 | }
80 |
81 | // Validate commit message.
82 | func (p MessageProcessorImpl) Validate(message string) error {
83 | subject, body := splitCommitMessageContent(message)
84 | msg, parseErr := p.Parse(subject, body)
85 |
86 | if parseErr != nil {
87 | return parseErr
88 | }
89 |
90 | if !regexp.MustCompile(`^[a-z+]+(\(.+\))?!?: .+$`).MatchString(subject) {
91 | return fmt.Errorf("subject [%s] should be valid according with conventional commits", subject)
92 | }
93 |
94 | if err := p.ValidateType(msg.Type); err != nil {
95 | return err
96 | }
97 |
98 | if err := p.ValidateScope(msg.Scope); err != nil {
99 | return err
100 | }
101 |
102 | if err := p.ValidateDescription(msg.Description); err != nil {
103 | return err
104 | }
105 |
106 | return nil
107 | }
108 |
109 | // ValidateType check if commit type is valid.
110 | func (p MessageProcessorImpl) ValidateType(ctype string) error {
111 | if ctype == "" || !contains(ctype, p.messageCfg.Types) {
112 | return fmt.Errorf("message type should be one of [%v]", strings.Join(p.messageCfg.Types, ", "))
113 | }
114 | return nil
115 | }
116 |
117 | // ValidateScope check if commit scope is valid.
118 | func (p MessageProcessorImpl) ValidateScope(scope string) error {
119 | if len(p.messageCfg.Scope.Values) > 0 && !contains(scope, p.messageCfg.Scope.Values) {
120 | return fmt.Errorf("message scope should one of [%v]", strings.Join(p.messageCfg.Scope.Values, ", "))
121 | }
122 | return nil
123 | }
124 |
125 | // ValidateDescription check if commit description is valid.
126 | func (p MessageProcessorImpl) ValidateDescription(description string) error {
127 | if !regexp.MustCompile("^[a-z]+.*$").MatchString(description) {
128 | return fmt.Errorf("description [%s] should begins with lowercase letter", description)
129 | }
130 | return nil
131 | }
132 |
133 | // Enhance add metadata on commit message.
134 | func (p MessageProcessorImpl) Enhance(branch string, message string) (string, error) {
135 | if p.branchesCfg.DisableIssue || p.messageCfg.IssueFooterConfig().Key == "" || hasIssueID(message, p.messageCfg.IssueFooterConfig()) {
136 | return "", nil // enhance disabled
137 | }
138 |
139 | issue, err := p.IssueID(branch)
140 | if err != nil {
141 | return "", err
142 | }
143 | if issue == "" {
144 | return "", fmt.Errorf("could not find issue id using configured regex")
145 | }
146 |
147 | footer := formatIssueFooter(p.messageCfg.IssueFooterConfig(), issue)
148 | if !hasFooter(message) {
149 | return "\n" + footer, nil
150 | }
151 |
152 | return footer, nil
153 | }
154 |
155 | func formatIssueFooter(cfg CommitMessageFooterConfig, issue string) string {
156 | if !strings.HasPrefix(issue, cfg.AddValuePrefix) {
157 | issue = cfg.AddValuePrefix + issue
158 | }
159 | if cfg.UseHash {
160 | return fmt.Sprintf("%s #%s", cfg.Key, strings.TrimPrefix(issue, "#"))
161 | }
162 | return fmt.Sprintf("%s: %s", cfg.Key, issue)
163 | }
164 |
165 | // IssueID try to extract issue id from branch, return empty if not found.
166 | func (p MessageProcessorImpl) IssueID(branch string) (string, error) {
167 | if p.branchesCfg.DisableIssue || p.messageCfg.Issue.Regex == "" {
168 | return "", nil
169 | }
170 |
171 | rstr := fmt.Sprintf("^%s(%s)%s$", p.branchesCfg.Prefix, p.messageCfg.Issue.Regex, p.branchesCfg.Suffix)
172 | r, err := regexp.Compile(rstr)
173 | if err != nil {
174 | return "", fmt.Errorf("could not compile issue regex: %s, error: %v", rstr, err.Error())
175 | }
176 |
177 | groups := r.FindStringSubmatch(branch)
178 | if len(groups) != 4 {
179 | return "", nil
180 | }
181 | return groups[2], nil
182 | }
183 |
184 | // Format a commit message returning header, body and footer.
185 | func (p MessageProcessorImpl) Format(msg CommitMessage) (string, string, string) {
186 | var header strings.Builder
187 | header.WriteString(msg.Type)
188 | if msg.Scope != "" {
189 | header.WriteString("(" + msg.Scope + ")")
190 | }
191 | header.WriteString(": ")
192 | header.WriteString(msg.Description)
193 |
194 | var footer strings.Builder
195 | if msg.BreakingMessage() != "" {
196 | footer.WriteString(fmt.Sprintf("%s: %s", breakingChangeFooterKey, msg.BreakingMessage()))
197 | }
198 | if issue, exists := msg.Metadata[issueMetadataKey]; exists && p.messageCfg.IssueFooterConfig().Key != "" {
199 | if footer.Len() > 0 {
200 | footer.WriteString("\n")
201 | }
202 | footer.WriteString(formatIssueFooter(p.messageCfg.IssueFooterConfig(), issue))
203 | }
204 |
205 | return header.String(), msg.Body, footer.String()
206 | }
207 |
208 | func removeCarriage(commit string) string {
209 | return regexp.MustCompile(`\r`).ReplaceAllString(commit, "")
210 | }
211 |
212 | // Parse a commit message.
213 | func (p MessageProcessorImpl) Parse(subject, body string) (CommitMessage, error) {
214 | preparedSubject, err := p.prepareHeader(subject)
215 | commitBody := removeCarriage(body)
216 |
217 | if err != nil {
218 | return CommitMessage{}, err
219 | }
220 |
221 | commitType, scope, description, hasBreakingChange := parseSubjectMessage(preparedSubject)
222 |
223 | metadata := make(map[string]string)
224 | for key, mdCfg := range p.messageCfg.Footer {
225 | if mdCfg.Key != "" {
226 | prefixes := append([]string{mdCfg.Key}, mdCfg.KeySynonyms...)
227 | for _, prefix := range prefixes {
228 | if tagValue := extractFooterMetadata(prefix, commitBody, mdCfg.UseHash); tagValue != "" {
229 | metadata[key] = tagValue
230 | break
231 | }
232 | }
233 | }
234 | }
235 | if tagValue := extractFooterMetadata(breakingChangeFooterKey, commitBody, false); tagValue != "" {
236 | metadata[breakingChangeMetadataKey] = tagValue
237 | hasBreakingChange = true
238 | }
239 |
240 | return CommitMessage{
241 | Type: commitType,
242 | Scope: scope,
243 | Description: description,
244 | Body: commitBody,
245 | IsBreakingChange: hasBreakingChange,
246 | Metadata: metadata,
247 | }, nil
248 | }
249 |
250 | func (p MessageProcessorImpl) prepareHeader(header string) (string, error) {
251 | if p.messageCfg.HeaderSelector == "" {
252 | return header, nil
253 | }
254 |
255 | regex, err := regexp.Compile(p.messageCfg.HeaderSelector)
256 | if err != nil {
257 | return "", fmt.Errorf("invalid regex on header-selector %s, error: %s", p.messageCfg.HeaderSelector, err.Error())
258 | }
259 |
260 | index := regex.SubexpIndex(messageRegexGroupName)
261 | if index < 0 {
262 | return "", fmt.Errorf("could not find %s regex group on header-selector regex", messageRegexGroupName)
263 | }
264 |
265 | match := regex.FindStringSubmatch(header)
266 |
267 | if match == nil || len(match) < index {
268 | return "", fmt.Errorf("could not find %s regex group in match result for '%s'", messageRegexGroupName, header)
269 | }
270 |
271 | return match[index], nil
272 | }
273 |
274 | func parseSubjectMessage(message string) (string, string, string, bool) {
275 | regex := regexp.MustCompile(`([a-z]+)(\((.*)\))?(!)?: (.*)`)
276 | result := regex.FindStringSubmatch(message)
277 | if len(result) != 6 {
278 | return "", "", message, false
279 | }
280 | return result[1], result[3], strings.TrimSpace(result[5]), result[4] == "!"
281 | }
282 |
283 | func extractFooterMetadata(key, text string, useHash bool) string {
284 | var regex *regexp.Regexp
285 | if useHash {
286 | regex = regexp.MustCompile(key + " (#.*)")
287 | } else {
288 | regex = regexp.MustCompile(key + ": (.*)")
289 | }
290 |
291 | result := regex.FindStringSubmatch(text)
292 | if len(result) < 2 {
293 | return ""
294 | }
295 | return result[1]
296 | }
297 |
298 | func hasFooter(message string) bool {
299 | r := regexp.MustCompile("^[a-zA-Z-]+: .*|^[a-zA-Z-]+ #.*|^" + breakingChangeFooterKey + ": .*")
300 |
301 | scanner := bufio.NewScanner(strings.NewReader(message))
302 | lines := 0
303 | for scanner.Scan() {
304 | if lines > 0 && r.MatchString(scanner.Text()) {
305 | return true
306 | }
307 | lines++
308 | }
309 |
310 | return false
311 | }
312 |
313 | func hasIssueID(message string, issueConfig CommitMessageFooterConfig) bool {
314 | var r *regexp.Regexp
315 | if issueConfig.UseHash {
316 | r = regexp.MustCompile(fmt.Sprintf("(?m)^%s #.+$", issueConfig.Key))
317 | } else {
318 | r = regexp.MustCompile(fmt.Sprintf("(?m)^%s: .+$", issueConfig.Key))
319 | }
320 | return r.MatchString(message)
321 | }
322 |
323 | func contains(value string, content []string) bool {
324 | for _, v := range content {
325 | if value == v {
326 | return true
327 | }
328 | }
329 | return false
330 | }
331 |
332 | func splitCommitMessageContent(content string) (string, string) {
333 | scanner := bufio.NewScanner(strings.NewReader(content))
334 |
335 | scanner.Scan()
336 | subject := scanner.Text()
337 |
338 | var body strings.Builder
339 | first := true
340 | for scanner.Scan() {
341 | if !first {
342 | body.WriteString("\n")
343 | }
344 | body.WriteString(scanner.Text())
345 | first = false
346 | }
347 |
348 | return subject, body.String()
349 | }
350 |
--------------------------------------------------------------------------------
/cmd/git-sv/handlers.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "os"
7 | "path/filepath"
8 | "sort"
9 | "strings"
10 | "time"
11 |
12 | "github.com/Masterminds/semver/v3"
13 | "github.com/bvieira/sv4git/v2/sv"
14 | "github.com/urfave/cli/v2"
15 | "gopkg.in/yaml.v3"
16 | )
17 |
18 | func configDefaultHandler() func(c *cli.Context) error {
19 | cfg := defaultConfig()
20 | return func(c *cli.Context) error {
21 | content, err := yaml.Marshal(&cfg)
22 | if err != nil {
23 | return err
24 | }
25 | fmt.Println(string(content))
26 | return nil
27 | }
28 | }
29 |
30 | func configShowHandler(cfg Config) func(c *cli.Context) error {
31 | return func(c *cli.Context) error {
32 | content, err := yaml.Marshal(&cfg)
33 | if err != nil {
34 | return err
35 | }
36 | fmt.Println(string(content))
37 | return nil
38 | }
39 | }
40 |
41 | func currentVersionHandler(git sv.Git) func(c *cli.Context) error {
42 | return func(c *cli.Context) error {
43 | lastTag := git.LastTag()
44 |
45 | currentVer, err := sv.ToVersion(lastTag)
46 | if err != nil {
47 | return fmt.Errorf("error parsing version: %s from git tag, message: %v", lastTag, err)
48 | }
49 | fmt.Printf("%d.%d.%d\n", currentVer.Major(), currentVer.Minor(), currentVer.Patch())
50 | return nil
51 | }
52 | }
53 |
54 | func nextVersionHandler(git sv.Git, semverProcessor sv.SemVerCommitsProcessor) func(c *cli.Context) error {
55 | return func(c *cli.Context) error {
56 | lastTag := git.LastTag()
57 |
58 | currentVer, err := sv.ToVersion(lastTag)
59 | if err != nil {
60 | return fmt.Errorf("error parsing version: %s from git tag, message: %v", lastTag, err)
61 | }
62 |
63 | commits, err := git.Log(sv.NewLogRange(sv.TagRange, lastTag, ""))
64 | if err != nil {
65 | return fmt.Errorf("error getting git log, message: %v", err)
66 | }
67 |
68 | nextVer, _ := semverProcessor.NextVersion(currentVer, commits)
69 | fmt.Printf("%d.%d.%d\n", nextVer.Major(), nextVer.Minor(), nextVer.Patch())
70 | return nil
71 | }
72 | }
73 |
74 | func commitLogHandler(git sv.Git) func(c *cli.Context) error {
75 | return func(c *cli.Context) error {
76 | var commits []sv.GitCommitLog
77 | var err error
78 | tagFlag := c.String("t")
79 | rangeFlag := c.String("r")
80 | startFlag := c.String("s")
81 | endFlag := c.String("e")
82 | if tagFlag != "" && (rangeFlag != string(sv.TagRange) || startFlag != "" || endFlag != "") {
83 | return fmt.Errorf("cannot define tag flag with range, start or end flags")
84 | }
85 |
86 | if tagFlag != "" {
87 | commits, err = getTagCommits(git, tagFlag)
88 | } else {
89 | r, rerr := logRange(git, rangeFlag, startFlag, endFlag)
90 | if rerr != nil {
91 | return rerr
92 | }
93 | commits, err = git.Log(r)
94 | }
95 | if err != nil {
96 | return fmt.Errorf("error getting git log, message: %v", err)
97 | }
98 |
99 | for _, commit := range commits {
100 | content, err := json.Marshal(commit)
101 | if err != nil {
102 | return err
103 | }
104 | fmt.Println(string(content))
105 | }
106 | return nil
107 | }
108 | }
109 |
110 | func getTagCommits(git sv.Git, tag string) ([]sv.GitCommitLog, error) {
111 | prev, _, err := getTags(git, tag)
112 | if err != nil {
113 | return nil, err
114 | }
115 | return git.Log(sv.NewLogRange(sv.TagRange, prev, tag))
116 | }
117 |
118 | func logRange(git sv.Git, rangeFlag, startFlag, endFlag string) (sv.LogRange, error) {
119 | switch rangeFlag {
120 | case string(sv.TagRange):
121 | return sv.NewLogRange(sv.TagRange, str(startFlag, git.LastTag()), endFlag), nil
122 | case string(sv.DateRange):
123 | return sv.NewLogRange(sv.DateRange, startFlag, endFlag), nil
124 | case string(sv.HashRange):
125 | return sv.NewLogRange(sv.HashRange, startFlag, endFlag), nil
126 | default:
127 | return sv.LogRange{}, fmt.Errorf("invalid range: %s, expected: %s, %s or %s", rangeFlag, sv.TagRange, sv.DateRange, sv.HashRange)
128 | }
129 | }
130 |
131 | func commitNotesHandler(git sv.Git, rnProcessor sv.ReleaseNoteProcessor, outputFormatter sv.OutputFormatter) func(c *cli.Context) error {
132 | return func(c *cli.Context) error {
133 | var date time.Time
134 |
135 | rangeFlag := c.String("r")
136 | lr, err := logRange(git, rangeFlag, c.String("s"), c.String("e"))
137 | if err != nil {
138 | return err
139 | }
140 |
141 | commits, err := git.Log(lr)
142 | if err != nil {
143 | return fmt.Errorf("error getting git log from range: %s, message: %v", rangeFlag, err)
144 | }
145 |
146 | if len(commits) > 0 {
147 | date, _ = time.Parse("2006-01-02", commits[0].Date)
148 | }
149 |
150 | output, err := outputFormatter.FormatReleaseNote(rnProcessor.Create(nil, "", date, commits))
151 | if err != nil {
152 | return fmt.Errorf("could not format release notes, message: %v", err)
153 | }
154 | fmt.Println(output)
155 | return nil
156 | }
157 | }
158 |
159 | func releaseNotesHandler(git sv.Git, semverProcessor sv.SemVerCommitsProcessor, rnProcessor sv.ReleaseNoteProcessor, outputFormatter sv.OutputFormatter) func(c *cli.Context) error {
160 | return func(c *cli.Context) error {
161 | var commits []sv.GitCommitLog
162 | var rnVersion *semver.Version
163 | var tag string
164 | var date time.Time
165 | var err error
166 |
167 | if tag = c.String("t"); tag != "" {
168 | rnVersion, date, commits, err = getTagVersionInfo(git, tag)
169 | } else {
170 | // TODO: should generate release notes if version was not updated?
171 | rnVersion, _, date, commits, err = getNextVersionInfo(git, semverProcessor)
172 | }
173 |
174 | if err != nil {
175 | return err
176 | }
177 |
178 | releasenote := rnProcessor.Create(rnVersion, tag, date, commits)
179 | output, err := outputFormatter.FormatReleaseNote(releasenote)
180 | if err != nil {
181 | return fmt.Errorf("could not format release notes, message: %v", err)
182 | }
183 | fmt.Println(output)
184 | return nil
185 | }
186 | }
187 |
188 | func getTagVersionInfo(git sv.Git, tag string) (*semver.Version, time.Time, []sv.GitCommitLog, error) {
189 | tagVersion, _ := sv.ToVersion(tag)
190 |
191 | previousTag, currentTag, err := getTags(git, tag)
192 | if err != nil {
193 | return nil, time.Time{}, nil, fmt.Errorf("error listing tags, message: %v", err)
194 | }
195 |
196 | commits, err := git.Log(sv.NewLogRange(sv.TagRange, previousTag, tag))
197 | if err != nil {
198 | return nil, time.Time{}, nil, fmt.Errorf("error getting git log from tag: %s, message: %v", tag, err)
199 | }
200 |
201 | return tagVersion, currentTag.Date, commits, nil
202 | }
203 |
204 | func getTags(git sv.Git, tag string) (string, sv.GitTag, error) {
205 | tags, err := git.Tags()
206 | if err != nil {
207 | return "", sv.GitTag{}, err
208 | }
209 |
210 | index := find(tag, tags)
211 | if index < 0 {
212 | return "", sv.GitTag{}, fmt.Errorf("tag: %s not found, check tag filter", tag)
213 | }
214 |
215 | previousTag := ""
216 | if index > 0 {
217 | previousTag = tags[index-1].Name
218 | }
219 | return previousTag, tags[index], nil
220 | }
221 |
222 | func find(tag string, tags []sv.GitTag) int {
223 | for i := 0; i < len(tags); i++ {
224 | if tag == tags[i].Name {
225 | return i
226 | }
227 | }
228 | return -1
229 | }
230 |
231 | func getNextVersionInfo(git sv.Git, semverProcessor sv.SemVerCommitsProcessor) (*semver.Version, bool, time.Time, []sv.GitCommitLog, error) {
232 | lastTag := git.LastTag()
233 |
234 | commits, err := git.Log(sv.NewLogRange(sv.TagRange, lastTag, ""))
235 | if err != nil {
236 | return nil, false, time.Time{}, nil, fmt.Errorf("error getting git log, message: %v", err)
237 | }
238 |
239 | currentVer, _ := sv.ToVersion(lastTag)
240 | version, updated := semverProcessor.NextVersion(currentVer, commits)
241 |
242 | return version, updated, time.Now(), commits, nil
243 | }
244 |
245 | func tagHandler(git sv.Git, semverProcessor sv.SemVerCommitsProcessor) func(c *cli.Context) error {
246 | return func(c *cli.Context) error {
247 | lastTag := git.LastTag()
248 |
249 | currentVer, err := sv.ToVersion(lastTag)
250 | if err != nil {
251 | return fmt.Errorf("error parsing version: %s from git tag, message: %v", lastTag, err)
252 | }
253 |
254 | commits, err := git.Log(sv.NewLogRange(sv.TagRange, lastTag, ""))
255 | if err != nil {
256 | return fmt.Errorf("error getting git log, message: %v", err)
257 | }
258 |
259 | nextVer, _ := semverProcessor.NextVersion(currentVer, commits)
260 | tagname, err := git.Tag(*nextVer)
261 | fmt.Println(tagname)
262 | if err != nil {
263 | return fmt.Errorf("error generating tag version: %s, message: %v", nextVer.String(), err)
264 | }
265 | return nil
266 | }
267 | }
268 |
269 | func getCommitType(cfg Config, p sv.MessageProcessor, input string) (string, error) {
270 | if input == "" {
271 | t, err := promptType(cfg.CommitMessage.Types)
272 | return t.Type, err
273 | }
274 | return input, p.ValidateType(input)
275 | }
276 |
277 | func getCommitScope(cfg Config, p sv.MessageProcessor, input string, noScope bool) (string, error) {
278 | if input == "" && !noScope {
279 | return promptScope(cfg.CommitMessage.Scope.Values)
280 | }
281 | return input, p.ValidateScope(input)
282 | }
283 |
284 | func getCommitDescription(p sv.MessageProcessor, input string) (string, error) {
285 | if input == "" {
286 | return promptSubject()
287 | }
288 | return input, p.ValidateDescription(input)
289 | }
290 |
291 | func getCommitBody(noBody bool) (string, error) {
292 | if noBody {
293 | return "", nil
294 | }
295 |
296 | var fullBody strings.Builder
297 | for body, err := promptBody(); body != "" || err != nil; body, err = promptBody() {
298 | if err != nil {
299 | return "", err
300 | }
301 | if fullBody.Len() > 0 {
302 | fullBody.WriteString("\n")
303 | }
304 | if body != "" {
305 | fullBody.WriteString(body)
306 | }
307 | }
308 | return fullBody.String(), nil
309 | }
310 |
311 | func getCommitIssue(cfg Config, p sv.MessageProcessor, branch string, noIssue bool) (string, error) {
312 | branchIssue, err := p.IssueID(branch)
313 | if err != nil {
314 | return "", err
315 | }
316 |
317 | if cfg.CommitMessage.IssueFooterConfig().Key == "" || cfg.CommitMessage.Issue.Regex == "" {
318 | return "", nil
319 | }
320 |
321 | if noIssue {
322 | return branchIssue, nil
323 | }
324 |
325 | return promptIssueID("issue id", cfg.CommitMessage.Issue.Regex, branchIssue)
326 | }
327 |
328 | func getCommitBreakingChange(noBreaking bool, input string) (string, error) {
329 | if noBreaking {
330 | return "", nil
331 | }
332 |
333 | if strings.TrimSpace(input) != "" {
334 | return input, nil
335 | }
336 |
337 | hasBreakingChanges, err := promptConfirm("has breaking change?")
338 | if err != nil {
339 | return "", err
340 | }
341 | if !hasBreakingChanges {
342 | return "", nil
343 | }
344 |
345 | return promptBreakingChanges()
346 | }
347 |
348 | func commitHandler(cfg Config, git sv.Git, messageProcessor sv.MessageProcessor) func(c *cli.Context) error {
349 | return func(c *cli.Context) error {
350 | noBreaking := c.Bool("no-breaking")
351 | noBody := c.Bool("no-body")
352 | noIssue := c.Bool("no-issue")
353 | noScope := c.Bool("no-scope")
354 | inputType := c.String("type")
355 | inputScope := c.String("scope")
356 | inputDescription := c.String("description")
357 | inputBreakingChange := c.String("breaking-change")
358 |
359 | ctype, err := getCommitType(cfg, messageProcessor, inputType)
360 | if err != nil {
361 | return err
362 | }
363 |
364 | scope, err := getCommitScope(cfg, messageProcessor, inputScope, noScope)
365 | if err != nil {
366 | return err
367 | }
368 |
369 | subject, err := getCommitDescription(messageProcessor, inputDescription)
370 | if err != nil {
371 | return err
372 | }
373 |
374 | fullBody, err := getCommitBody(noBody)
375 | if err != nil {
376 | return err
377 | }
378 |
379 | issue, err := getCommitIssue(cfg, messageProcessor, git.Branch(), noIssue)
380 | if err != nil {
381 | return err
382 | }
383 |
384 | breakingChange, err := getCommitBreakingChange(noBreaking, inputBreakingChange)
385 | if err != nil {
386 | return err
387 | }
388 |
389 | header, body, footer := messageProcessor.Format(sv.NewCommitMessage(ctype, scope, subject, fullBody, issue, breakingChange))
390 |
391 | err = git.Commit(header, body, footer)
392 | if err != nil {
393 | return fmt.Errorf("error executing git commit, message: %v", err)
394 | }
395 | return nil
396 | }
397 | }
398 |
399 | func changelogHandler(git sv.Git, semverProcessor sv.SemVerCommitsProcessor, rnProcessor sv.ReleaseNoteProcessor, formatter sv.OutputFormatter) func(c *cli.Context) error {
400 | return func(c *cli.Context) error {
401 | tags, err := git.Tags()
402 | if err != nil {
403 | return err
404 | }
405 | sort.Slice(tags, func(i, j int) bool {
406 | return tags[i].Date.After(tags[j].Date)
407 | })
408 |
409 | var releaseNotes []sv.ReleaseNote
410 |
411 | size := c.Int("size")
412 | all := c.Bool("all")
413 | addNextVersion := c.Bool("add-next-version")
414 | semanticVersionOnly := c.Bool("semantic-version-only")
415 |
416 | if addNextVersion {
417 | rnVersion, updated, date, commits, uerr := getNextVersionInfo(git, semverProcessor)
418 | if uerr != nil {
419 | return uerr
420 | }
421 | if updated {
422 | releaseNotes = append(releaseNotes, rnProcessor.Create(rnVersion, "", date, commits))
423 | }
424 | }
425 | for i, tag := range tags {
426 | if !all && i >= size {
427 | break
428 | }
429 |
430 | previousTag := ""
431 | if i+1 < len(tags) {
432 | previousTag = tags[i+1].Name
433 | }
434 |
435 | if semanticVersionOnly && !sv.IsValidVersion(tag.Name) {
436 | continue
437 | }
438 |
439 | commits, err := git.Log(sv.NewLogRange(sv.TagRange, previousTag, tag.Name))
440 | if err != nil {
441 | return fmt.Errorf("error getting git log from tag: %s, message: %v", tag.Name, err)
442 | }
443 |
444 | currentVer, _ := sv.ToVersion(tag.Name)
445 | releaseNotes = append(releaseNotes, rnProcessor.Create(currentVer, tag.Name, tag.Date, commits))
446 | }
447 |
448 | output, err := formatter.FormatChangelog(releaseNotes)
449 | if err != nil {
450 | return fmt.Errorf("could not format changelog, message: %v", err)
451 | }
452 | fmt.Println(output)
453 |
454 | return nil
455 | }
456 | }
457 |
458 | func validateCommitMessageHandler(git sv.Git, messageProcessor sv.MessageProcessor) func(c *cli.Context) error {
459 | return func(c *cli.Context) error {
460 | branch := git.Branch()
461 | detached, derr := git.IsDetached()
462 |
463 | if messageProcessor.SkipBranch(branch, derr == nil && detached) {
464 | warnf("commit message validation skipped, branch in ignore list or detached...")
465 | return nil
466 | }
467 |
468 | if source := c.String("source"); source == "merge" {
469 | warnf("commit message validation skipped, ignoring source: %s...", source)
470 | return nil
471 | }
472 |
473 | filepath := filepath.Join(c.String("path"), c.String("file"))
474 |
475 | commitMessage, err := readFile(filepath)
476 | if err != nil {
477 | return fmt.Errorf("failed to read commit message, error: %s", err.Error())
478 | }
479 |
480 | if err := messageProcessor.Validate(commitMessage); err != nil {
481 | return fmt.Errorf("invalid commit message, error: %s", err.Error())
482 | }
483 |
484 | msg, err := messageProcessor.Enhance(branch, commitMessage)
485 | if err != nil {
486 | warnf("could not enhance commit message, %s", err.Error())
487 | return nil
488 | }
489 | if msg == "" {
490 | return nil
491 | }
492 |
493 | if err := appendOnFile(msg, filepath); err != nil {
494 | return fmt.Errorf("failed to append meta-informations on footer, error: %s", err.Error())
495 | }
496 |
497 | return nil
498 | }
499 | }
500 |
501 | func readFile(filepath string) (string, error) {
502 | f, err := os.ReadFile(filepath)
503 | if err != nil {
504 | return "", err
505 | }
506 | return string(f), nil
507 | }
508 |
509 | func appendOnFile(message, filepath string) error {
510 | f, err := os.OpenFile(filepath, os.O_APPEND|os.O_WRONLY, 0644)
511 | if err != nil {
512 | return err
513 | }
514 | defer f.Close()
515 |
516 | _, err = f.WriteString(message)
517 | return err
518 | }
519 |
520 | func str(value, defaultValue string) string {
521 | if value != "" {
522 | return value
523 | }
524 | return defaultValue
525 | }
526 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
sv4git
3 | A command line tool (CLI) to validate commit messages, bump version, create tags and generate changelogs!
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | ## Getting Started
18 |
19 | ### Pre Requirements
20 |
21 | - Git 2.17+
22 |
23 | ### Installing
24 |
25 | - Download the latest release and add the binary to your path.
26 | - Optional: Set `SV4GIT_HOME` to define user configs. Check the [Config](#config) topic for more information.
27 |
28 | If you want to install from source using `go install`, just run:
29 |
30 | ```bash
31 | # keep in mind that with this, it will compile from source and won't show the version on cli -h.
32 | go install github.com/bvieira/sv4git/v2/cmd/git-sv@latest
33 |
34 | # if you want to add the version on the binary, run this command instead.
35 | SV4GIT_VERSION=$(go list -f '{{ .Version }}' -m github.com/bvieira/sv4git/v2@latest | sed 's/v//') && go install --ldflags "-X main.Version=$SV4GIT_VERSION" github.com/bvieira/sv4git/v2/cmd/git-sv@v$SV4GIT_VERSION
36 | ```
37 |
38 | ### Config
39 |
40 | #### YAML
41 |
42 | There are 3 config levels when using sv4git: [default](#default), [user](#user), [repository](#repository). All of them are merged considering the follow priority: **repository > user > default**.
43 |
44 | To see the current config, run:
45 |
46 | ```bash
47 | git sv cfg show
48 | ```
49 |
50 | ##### Configuration Types
51 |
52 | ###### Default
53 |
54 | To check the default configuration, run:
55 |
56 | ```bash
57 | git sv cfg default
58 | ```
59 |
60 | ###### User
61 |
62 | For user config, it is necessary to define the `SV4GIT_HOME` environment variable, eg.:
63 |
64 | ```bash
65 | SV4GIT_HOME=/home/myuser/.sv4git # myuser is just an example.
66 | ```
67 |
68 | And create a `config.yml` file inside it, eg.:
69 |
70 | ```bash
71 | .sv4git
72 | └── config.yml
73 | ```
74 |
75 | ###### Repository
76 |
77 | Create a `.sv4git.yml` file on the root of your repository, eg.: [.sv4git.yml](.sv4git.yml).
78 |
79 | ##### Configuration format
80 |
81 | ```yml
82 | version: "1.1" #config version
83 |
84 | versioning: # versioning bump
85 | update-major: [] # Commit types used to bump major.
86 | update-minor: [feat] # Commit types used to bump minor.
87 | update-patch: [build, ci, chore, fix, perf, refactor, test] # Commit types used to bump patch.
88 | # When type is not present on update rules and is unknown (not mapped on commit message types);
89 | # if ignore-unknown=false bump patch, if ignore-unknown=true do not bump version
90 | ignore-unknown: false
91 |
92 | tag:
93 | pattern: '%d.%d.%d' # Pattern used to create git tag.
94 | filter: '' # Enables you to filter for considerable tags using git pattern syntax
95 |
96 | release-notes:
97 | # Deprecated!!! please use 'sections' instead!
98 | # Headers names for release notes markdown. To disable a section just remove the header
99 | # line. It's possible to add other commit types, the release note will be created
100 | # respecting the following order: feat, fix, refactor, perf, test, build, ci, chore, docs, style, breaking-change.
101 | headers:
102 | breaking-change: Breaking Changes
103 | feat: Features
104 | fix: Bug Fixes
105 |
106 | sections: # Array with each section of release note. Check template section for more information.
107 | - name: Features # Name used on section.
108 | section-type: commits # Type of the section, supported types: commits, breaking-changes.
109 | commit-types: [feat] # Commit types for commit section-type, one commit type cannot be in more than one section.
110 | - name: Bug Fixes
111 | section-type: commits
112 | commit-types: [fix]
113 | - name: Breaking Changes
114 | section-type: breaking-changes
115 |
116 | branches: # Git branches config.
117 | prefix: ([a-z]+\/)? # Prefix used on branch name, it should be a regex group.
118 | suffix: (-.*)? # Suffix used on branch name, it should be a regex group.
119 | disable-issue: false # Set true if there is no need to recover issue id from branch name.
120 | skip: [master, main, developer] # List of branch names ignored on commit message validation.
121 | skip-detached: false # Set true if a detached branch should be ignored on commit message validation.
122 |
123 | commit-message:
124 | types: [build, ci, chore, docs, feat, fix, perf, refactor, revert, style, test] # Supported commit types.
125 | header-selector: '' # You can put in a regex here to select only a certain part of the commit message. Please define a regex group 'header'.
126 | scope:
127 | # Define supported scopes, if blank, scope will not be validated, if not, only scope listed will be valid.
128 | # Don't forget to add "" on your list if you need to define scopes and keep it optional.
129 | values: []
130 | footer:
131 | issue: # Use "issue: {}" if you wish to disable issue footer.
132 | key: jira # Name used to define an issue on footer metadata.
133 | key-synonyms: [Jira, JIRA] # Supported variations for footer metadata.
134 | use-hash: false # If false, use : separator. If true, use # separator.
135 | add-value-prefix: '' # Add a prefix to issue value.
136 | issue:
137 | regex: '[A-Z]+-[0-9]+' # Regex for issue id.
138 | ```
139 |
140 | #### Templates
141 |
142 | **sv4git** uses *go templates* to format the output for `release-notes` and `changelog`, to see how the default template is configured check [template directory](cmd/git-sv/resources/templates). On v2.7.0+, its possible to overwrite the default configuration by adding `.sv4git/templates` on your repository. The cli expects that at least 2 files exists on your directory: `changelog-md.tpl` and `releasenotes-md.tpl`.
143 |
144 | ```bash
145 | .sv4git
146 | └── templates
147 | ├── changelog-md.tpl
148 | └── releasenotes-md.tpl
149 | ```
150 |
151 | Everything inside `.sv4git/templates` will be loaded, so it's possible to add more files to be used as needed.
152 |
153 | ##### Variables
154 |
155 | To execute the template the `releasenotes-md.tpl` will receive a single **ReleaseNote** and `changelog-md.tpl` will receive a list of **ReleaseNote** as variables.
156 |
157 | Each **ReleaseNoteSection** will be configured according with `release-notes.section` from config file. The order for each section will be maintained and the **SectionType** is defined according with `section-type` attribute as described on the table below.
158 |
159 | | section-type | ReleaseNoteSection |
160 | | -- | -- |
161 | | commits | ReleaseNoteCommitsSection |
162 | | breaking-changes | ReleaseNoteBreakingChangeSection |
163 |
164 | > :warning: currently only `commits` and `breaking-changes` are supported as `section-types`, using a different value for this field will make the section to be removed from the template variables.
165 |
166 | Check below the variables available:
167 |
168 | ```go
169 | ReleaseNote
170 | Release string // 'v' followed by version if present, if not tag will be used instead.
171 | Tag string // Current tag, if available.
172 | Version *Version // Version from tag or next version according with semver.
173 | Date time.Time
174 | Sections []ReleaseNoteSection // ReleaseNoteCommitsSection or ReleaseNoteBreakingChangeSection
175 | AuthorNames []string // Author names recovered from commit message (user.name from git)
176 |
177 | Version
178 | Major int
179 | Minor int
180 | Patch int
181 | Prerelease string
182 | Metadata string
183 | Original string
184 |
185 | ReleaseNoteCommitsSection // SectionType == commits
186 | SectionType string
187 | SectionName string
188 | Types []string
189 | Items []GitCommitLog
190 | HasMultipleTypes bool
191 |
192 | ReleaseNoteBreakingChangeSection // SectionType == breaking-changes
193 | SectionType string
194 | SectionName string
195 | Messages []string
196 |
197 | GitCommitLog
198 | Date string
199 | Timestamp int
200 | AuthorName string
201 | Hash string
202 | Message CommitMessage
203 |
204 | CommitMessage
205 | Type string
206 | Scope string
207 | Description string
208 | Body string
209 | IsBreakingChange bool
210 | Metadata map[string]string
211 | ```
212 |
213 | ##### Functions
214 |
215 | Beside the [go template functions](https://pkg.go.dev/text/template#hdr-Functions), the folowing functions are availiable to use in the templates. Check [formatter_functions.go](sv/formatter_functions.go) to see the functions implementation.
216 |
217 | ###### timefmt
218 |
219 | **Usage:** timefmt time "2006-01-02"
220 |
221 | Receive a time.Time and a layout string and returns a textual representation of the time according with the layout provided. Check for more information.
222 |
223 | ###### getsection
224 |
225 | **Usage:** getsection sections "Features"
226 |
227 | Receive a list of ReleaseNoteSection and a Section name and returns a section with the provided name. If no section is found, it will return `nil`.
228 |
229 | ### Running
230 |
231 | Run `git-sv` to get the list of available parameters:
232 |
233 | ```bash
234 | git-sv
235 | ```
236 |
237 | #### Run as git command
238 |
239 | If `git-sv` is configured on your path, you can use it like a git command:
240 |
241 | ```bash
242 | git sv
243 | git sv current-version
244 | git sv next-version
245 | ```
246 |
247 | #### Usage
248 |
249 | Use `--help` or `-h` to get usage information, don't forget that some commands have unique options too:
250 |
251 | ```bash
252 | # sv help
253 | git-sv -h
254 |
255 | # sv release-notes command help
256 | git-sv rn -h
257 | ```
258 |
259 | ##### Available commands
260 |
261 | | Variable | description | has options or subcommands |
262 | | ---------------------------- | -------------------------------------------------------------- | :------------------------: |
263 | | config, cfg | Show config information. | :heavy_check_mark: |
264 | | current-version, cv | Get last released version from git. | :x: |
265 | | next-version, nv | Generate the next version based on git commit messages. | :x: |
266 | | commit-log, cl | List all commit logs according to range as jsons. | :heavy_check_mark: |
267 | | commit-notes, cn | Generate a commit notes according to range. | :heavy_check_mark: |
268 | | release-notes, rn | Generate release notes. | :heavy_check_mark: |
269 | | changelog, cgl | Generate changelog. | :heavy_check_mark: |
270 | | tag, tg | Generate tag with version based on git commit messages. | :x: |
271 | | commit, cmt | Execute git commit with convetional commit message helper. | :heavy_check_mark: |
272 | | validate-commit-message, vcm | Use as prepare-commit-message hook to validate commit message. | :heavy_check_mark: |
273 | | help, h | Shows a list of commands or help for one command. | :x: |
274 |
275 | ##### Use range
276 |
277 | Commands like `commit-log` and `commit-notes` has a range option. Supported range types are: `tag`, `date` and `hash`.
278 |
279 | By default, it's used [--date=short](https://git-scm.com/docs/git-log#Documentation/git-log.txt---dateltformatgt) at `git log`, all dates returned from it will be in `YYYY-MM-DD` format.
280 |
281 | Range `tag` will use `git for-each-ref refs/tags` to get the last tag available if `start` is empty, the others types won't use the existing tags. It's recommended to always use a start limit in a old repository with a lot of commits. This behavior was maintained to not break the retrocompatibility.
282 |
283 | Range `date` use git log `--since` and `--until`. It's possible to use all supported formats from [git log](https://git-scm.com/docs/git-log#Documentation/git-log.txt---sinceltdategt). If `end` is in `YYYY-MM-DD` format, `sv` will add a day on git log command to make the end date inclusive.
284 |
285 | Range `tag` and `hash` are used on git log [revision range](https://git-scm.com/docs/git-log#Documentation/git-log.txt-ltrevisionrangegt). If `end` is empty, `HEAD` will be used instead.
286 |
287 | ```bash
288 | # get commit log as json using a inclusive range
289 | git-sv commit-log --range hash --start 7ea9306~1 --end c444318
290 |
291 | # return all commits after last tag
292 | git-sv commit-log --range tag
293 | ```
294 |
295 | ##### Use validate-commit-message as prepare-commit-msg hook
296 |
297 | Configure your `.git/hooks/prepare-commit-msg`:
298 |
299 | ```bash
300 | #!/bin/sh
301 |
302 | COMMIT_MSG_FILE=$1
303 | COMMIT_SOURCE=$2
304 | SHA1=$3
305 |
306 | git sv vcm --path "$(pwd)" --file "$COMMIT_MSG_FILE" --source "$COMMIT_SOURCE"
307 | ```
308 |
309 | **Tip**: you can configure a directory as your global git templates using the command below:
310 |
311 | ```bash
312 | git config --global init.templatedir ''
313 | ```
314 |
315 | Check [git config docs](https://git-scm.com/docs/git-config#Documentation/git-config.txt-inittemplateDir) for more information!
316 |
317 | ## Development
318 |
319 | ### Makefile
320 |
321 | Run `make` to get the list of available actions:
322 |
323 | ```bash
324 | make
325 | ```
326 |
327 | #### Make configs
328 |
329 | | Variable | description |
330 | | ---------- | ----------------------- |
331 | | BUILDOS | Build OS. |
332 | | BUILDARCH | Build arch. |
333 | | ECHOFLAGS | Flags used on echo. |
334 | | BUILDENVS | Var envs used on build. |
335 | | BUILDFLAGS | Flags used on build. |
336 |
337 | | Parameters | description |
338 | | ---------- | ------------------------------------ |
339 | | args | Parameters that will be used on run. |
340 |
341 | ```bash
342 | #variables
343 | BUILDOS="linux" BUILDARCH="amd64" make build
344 |
345 | #parameters
346 | make run args="-h"
347 | ```
348 |
349 | ### Build
350 |
351 | ```bash
352 | make build
353 | ```
354 |
355 | The binary will be created on `bin/$BUILDOS_$BUILDARCH/git-sv`.
356 |
357 | ### Tests
358 |
359 | ```bash
360 | make test
361 | ```
362 |
363 | ### Run
364 |
365 | ```bash
366 | #without args
367 | make run
368 |
369 | #with args
370 | make run args="-h"
371 | ```
372 |
--------------------------------------------------------------------------------
/sv/message_test.go:
--------------------------------------------------------------------------------
1 | package sv
2 |
3 | import (
4 | "reflect"
5 | "testing"
6 | )
7 |
8 | var ccfg = CommitMessageConfig{
9 | Types: []string{"feat", "fix"},
10 | Scope: CommitMessageScopeConfig{},
11 | Footer: map[string]CommitMessageFooterConfig{
12 | "issue": {Key: "jira", KeySynonyms: []string{"Jira"}},
13 | "refs": {Key: "Refs", UseHash: true},
14 | },
15 | Issue: CommitMessageIssueConfig{Regex: "[A-Z]+-[0-9]+"},
16 | }
17 |
18 | var ccfgHash = CommitMessageConfig{
19 | Types: []string{"feat", "fix"},
20 | Scope: CommitMessageScopeConfig{},
21 | Footer: map[string]CommitMessageFooterConfig{
22 | "issue": {Key: "jira", KeySynonyms: []string{"Jira"}, UseHash: true},
23 | "refs": {Key: "Refs", UseHash: true},
24 | },
25 | Issue: CommitMessageIssueConfig{Regex: "[A-Z]+-[0-9]+"},
26 | }
27 |
28 | var ccfgGitIssue = CommitMessageConfig{
29 | Types: []string{"feat", "fix"},
30 | Scope: CommitMessageScopeConfig{},
31 | Footer: map[string]CommitMessageFooterConfig{
32 | "issue": {Key: "issue", KeySynonyms: []string{"Issue"}, UseHash: false, AddValuePrefix: "#"},
33 | },
34 | Issue: CommitMessageIssueConfig{Regex: "#?[0-9]+"},
35 | }
36 |
37 | var ccfgEmptyIssue = CommitMessageConfig{
38 | Types: []string{"feat", "fix"},
39 | Scope: CommitMessageScopeConfig{},
40 | Footer: map[string]CommitMessageFooterConfig{
41 | "issue": {},
42 | },
43 | Issue: CommitMessageIssueConfig{Regex: "[A-Z]+-[0-9]+"},
44 | }
45 |
46 | var ccfgWithScope = CommitMessageConfig{
47 | Types: []string{"feat", "fix"},
48 | Scope: CommitMessageScopeConfig{Values: []string{"", "scope"}},
49 | Footer: map[string]CommitMessageFooterConfig{
50 | "issue": {Key: "jira", KeySynonyms: []string{"Jira"}},
51 | "refs": {Key: "Refs", UseHash: true},
52 | },
53 | Issue: CommitMessageIssueConfig{Regex: "[A-Z]+-[0-9]+"},
54 | }
55 |
56 | func newBranchCfg(skipDetached bool) BranchesConfig {
57 | return BranchesConfig{
58 | Prefix: "([a-z]+\\/)?",
59 | Suffix: "(-.*)?",
60 | Skip: []string{"develop", "master"},
61 | SkipDetached: &skipDetached,
62 | }
63 | }
64 |
65 | func newCommitMessageCfg(headerSelector string) CommitMessageConfig {
66 | return CommitMessageConfig{
67 | Types: []string{"feat", "fix"},
68 | Scope: CommitMessageScopeConfig{Values: []string{"", "scope"}},
69 | Footer: map[string]CommitMessageFooterConfig{
70 | "issue": {Key: "jira", KeySynonyms: []string{"Jira"}},
71 | "refs": {Key: "Refs", UseHash: true},
72 | },
73 | Issue: CommitMessageIssueConfig{Regex: "[A-Z]+-[0-9]+"},
74 | HeaderSelector: headerSelector,
75 | }
76 | }
77 |
78 | // messages samples start.
79 | var fullMessage = `fix: correct minor typos in code
80 |
81 | see the issue for details
82 |
83 | on typos fixed.
84 |
85 | Reviewed-by: Z
86 | Refs #133`
87 |
88 | var fullMessageWithJira = `fix: correct minor typos in code
89 |
90 | see the issue for details
91 |
92 | on typos fixed.
93 |
94 | Reviewed-by: Z
95 | Refs #133
96 | jira: JIRA-456`
97 |
98 | var fullMessageRefs = `fix: correct minor typos in code
99 |
100 | see the issue for details
101 |
102 | on typos fixed.
103 |
104 | Refs #133`
105 |
106 | var subjectAndBodyMessage = `fix: correct minor typos in code
107 |
108 | see the issue for details
109 |
110 | on typos fixed.`
111 |
112 | var subjectAndFooterMessage = `refactor!: drop support for Node 6
113 |
114 | BREAKING CHANGE: refactor to use JavaScript features not available in Node 6.`
115 |
116 | // multiline samples end
117 |
118 | func TestMessageProcessorImpl_SkipBranch(t *testing.T) {
119 | tests := []struct {
120 | name string
121 | bcfg BranchesConfig
122 | branch string
123 | detached bool
124 | want bool
125 | }{
126 | {"normal branch", newBranchCfg(false), "JIRA-123", false, false},
127 | {"dont ignore detached branch", newBranchCfg(false), "JIRA-123", true, false},
128 | {"ignore branch on skip list", newBranchCfg(false), "master", false, true},
129 | {"ignore detached branch", newBranchCfg(true), "JIRA-123", true, true},
130 | {"null skip detached", BranchesConfig{Skip: []string{}}, "JIRA-123", true, false},
131 | }
132 | for _, tt := range tests {
133 | t.Run(tt.name, func(t *testing.T) {
134 | p := NewMessageProcessor(ccfg, tt.bcfg)
135 | if got := p.SkipBranch(tt.branch, tt.detached); got != tt.want {
136 | t.Errorf("MessageProcessorImpl.SkipBranch() = %v, want %v", got, tt.want)
137 | }
138 | })
139 | }
140 | }
141 |
142 | func TestMessageProcessorImpl_Validate(t *testing.T) {
143 | tests := []struct {
144 | name string
145 | cfg CommitMessageConfig
146 | message string
147 | wantErr bool
148 | }{
149 | {"single line valid message", ccfg, "feat: add something", false},
150 | {"single line valid message with scope", ccfg, "feat(scope): add something", false},
151 | {"single line valid scope from list", ccfgWithScope, "feat(scope): add something", false},
152 | {"single line invalid scope from list", ccfgWithScope, "feat(invalid): add something", true},
153 | {"single line invalid type message", ccfg, "something: add something", true},
154 | {"single line invalid type message", ccfg, "feat?: add something", true},
155 |
156 | {"multi line valid message", ccfg, `feat: add something
157 |
158 | team: x`, false},
159 |
160 | {"multi line invalid message", ccfg, `feat add something
161 |
162 | team: x`, true},
163 |
164 | {"support ! for breaking change", ccfg, "feat!: add something", false},
165 | {"support ! with scope for breaking change", ccfg, "feat(scope)!: add something", false},
166 | }
167 | for _, tt := range tests {
168 | t.Run(tt.name, func(t *testing.T) {
169 | p := NewMessageProcessor(tt.cfg, newBranchCfg(false))
170 | if err := p.Validate(tt.message); (err != nil) != tt.wantErr {
171 | t.Errorf("MessageProcessorImpl.Validate() error = %v, wantErr %v", err, tt.wantErr)
172 | }
173 | })
174 | }
175 | }
176 |
177 | func TestMessageProcessorImpl_ValidateType(t *testing.T) {
178 | tests := []struct {
179 | name string
180 | cfg CommitMessageConfig
181 | ctype string
182 | wantErr bool
183 | }{
184 | {"valid type", ccfg, "feat", false},
185 | {"invalid type", ccfg, "aaa", true},
186 | {"empty type", ccfg, "", true},
187 | }
188 | for _, tt := range tests {
189 | t.Run(tt.name, func(t *testing.T) {
190 | p := NewMessageProcessor(tt.cfg, newBranchCfg(false))
191 | if err := p.ValidateType(tt.ctype); (err != nil) != tt.wantErr {
192 | t.Errorf("MessageProcessorImpl.ValidateType() error = %v, wantErr %v", err, tt.wantErr)
193 | }
194 | })
195 | }
196 | }
197 |
198 | func TestMessageProcessorImpl_ValidateScope(t *testing.T) {
199 | tests := []struct {
200 | name string
201 | cfg CommitMessageConfig
202 | scope string
203 | wantErr bool
204 | }{
205 | {"any scope", ccfg, "aaa", false},
206 | {"valid scope with scope list", ccfgWithScope, "scope", false},
207 | {"invalid scope with scope list", ccfgWithScope, "aaa", true},
208 | }
209 | for _, tt := range tests {
210 | t.Run(tt.name, func(t *testing.T) {
211 | p := NewMessageProcessor(tt.cfg, newBranchCfg(false))
212 | if err := p.ValidateScope(tt.scope); (err != nil) != tt.wantErr {
213 | t.Errorf("MessageProcessorImpl.ValidateScope() error = %v, wantErr %v", err, tt.wantErr)
214 | }
215 | })
216 | }
217 | }
218 |
219 | func TestMessageProcessorImpl_ValidateDescription(t *testing.T) {
220 | tests := []struct {
221 | name string
222 | cfg CommitMessageConfig
223 | description string
224 | wantErr bool
225 | }{
226 | {"empty description", ccfg, "", true},
227 | {"sigle letter description", ccfg, "a", false},
228 | {"number description", ccfg, "1", true},
229 | {"valid description", ccfg, "add some feature", false},
230 | {"invalid capital letter description", ccfg, "Add some feature", true},
231 | }
232 | for _, tt := range tests {
233 | t.Run(tt.name, func(t *testing.T) {
234 | p := NewMessageProcessor(tt.cfg, newBranchCfg(false))
235 | if err := p.ValidateDescription(tt.description); (err != nil) != tt.wantErr {
236 | t.Errorf("MessageProcessorImpl.ValidateDescription() error = %v, wantErr %v", err, tt.wantErr)
237 | }
238 | })
239 | }
240 | }
241 |
242 | func TestMessageProcessorImpl_Enhance(t *testing.T) {
243 | tests := []struct {
244 | name string
245 | cfg CommitMessageConfig
246 | branch string
247 | message string
248 | want string
249 | wantErr bool
250 | }{
251 | {"issue on branch name", ccfg, "JIRA-123", "fix: fix something", "\njira: JIRA-123", false},
252 | {"issue on branch name with description", ccfg, "JIRA-123-some-description", "fix: fix something", "\njira: JIRA-123", false},
253 | {"issue on branch name with prefix", ccfg, "feature/JIRA-123", "fix: fix something", "\njira: JIRA-123", false},
254 | {"with footer", ccfg, "JIRA-123", fullMessage, "jira: JIRA-123", false},
255 | {"with issue on footer", ccfg, "JIRA-123", fullMessageWithJira, "", false},
256 | {"issue on branch name with prefix and description", ccfg, "feature/JIRA-123-some-description", "fix: fix something", "\njira: JIRA-123", false},
257 | {"no issue on branch name", ccfg, "branch", "fix: fix something", "", true},
258 | {"unexpected branch name", ccfg, "feature /JIRA-123", "fix: fix something", "", true},
259 | {"issue on branch name using hash", ccfgHash, "JIRA-123-some-description", "fix: fix something", "\njira #JIRA-123", false},
260 | {"numeric issue on branch name", ccfgGitIssue, "#13", "fix: fix something", "\nissue: #13", false},
261 | {"numeric issue on branch name without hash", ccfgGitIssue, "13", "fix: fix something", "\nissue: #13", false},
262 | {"numeric issue on branch name with description without hash", ccfgGitIssue, "13-some-fix", "fix: fix something", "\nissue: #13", false},
263 | }
264 | for _, tt := range tests {
265 | t.Run(tt.name, func(t *testing.T) {
266 | got, err := NewMessageProcessor(tt.cfg, newBranchCfg(false)).Enhance(tt.branch, tt.message)
267 | if (err != nil) != tt.wantErr {
268 | t.Errorf("MessageProcessorImpl.Enhance() error = %v, wantErr %v", err, tt.wantErr)
269 | return
270 | }
271 | if got != tt.want {
272 | t.Errorf("MessageProcessorImpl.Enhance() = %v, want %v", got, tt.want)
273 | }
274 | })
275 | }
276 | }
277 |
278 | func TestMessageProcessorImpl_IssueID(t *testing.T) {
279 | p := NewMessageProcessor(ccfg, newBranchCfg(false))
280 |
281 | tests := []struct {
282 | name string
283 | branch string
284 | want string
285 | wantErr bool
286 | }{
287 | {"simple branch", "JIRA-123", "JIRA-123", false},
288 | {"branch with prefix", "feature/JIRA-123", "JIRA-123", false},
289 | {"branch with prefix and posfix", "feature/JIRA-123-some-description", "JIRA-123", false},
290 | {"branch not found", "feature/wrong123-some-description", "", false},
291 | {"empty branch", "", "", false},
292 | {"unexpected branch name", "feature /JIRA-123", "", false},
293 | }
294 | for _, tt := range tests {
295 | t.Run(tt.name, func(t *testing.T) {
296 | got, err := p.IssueID(tt.branch)
297 | if (err != nil) != tt.wantErr {
298 | t.Errorf("MessageProcessorImpl.IssueID() error = %v, wantErr %v", err, tt.wantErr)
299 | return
300 | }
301 | if got != tt.want {
302 | t.Errorf("MessageProcessorImpl.IssueID() = %v, want %v", got, tt.want)
303 | }
304 | })
305 | }
306 | }
307 |
308 | const (
309 | multilineBody = `a
310 | b
311 | c`
312 | fullFooter = `BREAKING CHANGE: breaks
313 | jira: JIRA-123`
314 | )
315 |
316 | func Test_hasIssueID(t *testing.T) {
317 | cfgColon := CommitMessageFooterConfig{Key: "jira"}
318 | cfgHash := CommitMessageFooterConfig{Key: "jira", UseHash: true}
319 | cfgEmpty := CommitMessageFooterConfig{}
320 |
321 | tests := []struct {
322 | name string
323 | message string
324 | issueCfg CommitMessageFooterConfig
325 | want bool
326 | }{
327 | {"single line without issue", "feat: something", cfgColon, false},
328 | {"multi line without issue", `feat: something
329 |
330 | yay`, cfgColon, false},
331 | {"multi line without jira issue", `feat: something
332 |
333 | jira1: JIRA-123`, cfgColon, false},
334 | {"multi line with issue", `feat: something
335 |
336 | jira: JIRA-123`, cfgColon, true},
337 | {"multi line with issue and hash", `feat: something
338 |
339 | jira #JIRA-123`, cfgHash, true},
340 | {"empty config", `feat: something
341 |
342 | jira #JIRA-123`, cfgEmpty, false},
343 | }
344 | for _, tt := range tests {
345 | t.Run(tt.name, func(t *testing.T) {
346 | if got := hasIssueID(tt.message, tt.issueCfg); got != tt.want {
347 | t.Errorf("hasIssueID() = %v, want %v", got, tt.want)
348 | }
349 | })
350 | }
351 | }
352 |
353 | func Test_hasFooter(t *testing.T) {
354 | tests := []struct {
355 | name string
356 | message string
357 | want bool
358 | }{
359 | {"simple message", "feat: add something", false},
360 | {"full messsage", fullMessage, true},
361 | {"full messsage with refs", fullMessageRefs, true},
362 | {"subject and footer message", subjectAndFooterMessage, true},
363 | {"subject and body message", subjectAndBodyMessage, false},
364 | }
365 | for _, tt := range tests {
366 | t.Run(tt.name, func(t *testing.T) {
367 | if got := hasFooter(tt.message); got != tt.want {
368 | t.Errorf("hasFooter() = %v, want %v", got, tt.want)
369 | }
370 | })
371 | }
372 | }
373 |
374 | // conventional commit tests
375 |
376 | var completeBody = `some descriptions
377 |
378 | jira: JIRA-123
379 | BREAKING CHANGE: this change breaks everything`
380 |
381 | var bodyWithCarriage = "some description\r\nmore description\r\n\r\njira: JIRA-123\r"
382 | var expectedBodyWithCarriage = "some description\nmore description\n\njira: JIRA-123"
383 |
384 | var issueOnlyBody = `some descriptions
385 |
386 | jira: JIRA-456`
387 |
388 | var issueSynonymsBody = `some descriptions
389 |
390 | Jira: JIRA-789`
391 |
392 | var hashMetadataBody = `some descriptions
393 |
394 | Jira: JIRA-999
395 | Refs #123`
396 |
397 | func TestMessageProcessorImpl_Parse(t *testing.T) {
398 | tests := []struct {
399 | name string
400 | cfg CommitMessageConfig
401 | subject string
402 | body string
403 | want CommitMessage
404 | }{
405 | {"simple message", ccfg, "feat: something awesome", "", CommitMessage{Type: "feat", Scope: "", Description: "something awesome", Body: "", IsBreakingChange: false, Metadata: map[string]string{}}},
406 | {"message with scope", ccfg, "feat(scope): something awesome", "", CommitMessage{Type: "feat", Scope: "scope", Description: "something awesome", Body: "", IsBreakingChange: false, Metadata: map[string]string{}}},
407 | {"unmapped type", ccfg, "unkn: something unknown", "", CommitMessage{Type: "unkn", Scope: "", Description: "something unknown", Body: "", IsBreakingChange: false, Metadata: map[string]string{}}},
408 | {"jira and breaking change metadata", ccfg, "feat: something new", completeBody, CommitMessage{Type: "feat", Scope: "", Description: "something new", Body: completeBody, IsBreakingChange: true, Metadata: map[string]string{issueMetadataKey: "JIRA-123", breakingChangeMetadataKey: "this change breaks everything"}}},
409 | {"jira only metadata", ccfg, "feat: something new", issueOnlyBody, CommitMessage{Type: "feat", Scope: "", Description: "something new", Body: issueOnlyBody, IsBreakingChange: false, Metadata: map[string]string{issueMetadataKey: "JIRA-456"}}},
410 | {"jira synonyms metadata", ccfg, "feat: something new", issueSynonymsBody, CommitMessage{Type: "feat", Scope: "", Description: "something new", Body: issueSynonymsBody, IsBreakingChange: false, Metadata: map[string]string{issueMetadataKey: "JIRA-789"}}},
411 | {"breaking change with exclamation mark", ccfg, "feat!: something new", "", CommitMessage{Type: "feat", Scope: "", Description: "something new", Body: "", IsBreakingChange: true, Metadata: map[string]string{}}},
412 | {"hash metadata", ccfg, "feat: something new", hashMetadataBody, CommitMessage{Type: "feat", Scope: "", Description: "something new", Body: hashMetadataBody, IsBreakingChange: false, Metadata: map[string]string{issueMetadataKey: "JIRA-999", "refs": "#123"}}},
413 | {"empty issue cfg", ccfgEmptyIssue, "feat: something new", hashMetadataBody, CommitMessage{Type: "feat", Scope: "", Description: "something new", Body: hashMetadataBody, IsBreakingChange: false, Metadata: map[string]string{}}},
414 | {"carriage return on body", ccfg, "feat: something new", bodyWithCarriage, CommitMessage{Type: "feat", Scope: "", Description: "something new", Body: expectedBodyWithCarriage, IsBreakingChange: false, Metadata: map[string]string{issueMetadataKey: "JIRA-123"}}},
415 | }
416 | for _, tt := range tests {
417 | t.Run(tt.name, func(t *testing.T) {
418 | if got, err := NewMessageProcessor(tt.cfg, newBranchCfg(false)).Parse(tt.subject, tt.body); !reflect.DeepEqual(got, tt.want) && err == nil {
419 | t.Errorf("MessageProcessorImpl.Parse() = [%+v], want [%+v]", got, tt.want)
420 | }
421 | })
422 | }
423 | }
424 |
425 | func TestMessageProcessorImpl_Format(t *testing.T) {
426 | tests := []struct {
427 | name string
428 | cfg CommitMessageConfig
429 | msg CommitMessage
430 | wantHeader string
431 | wantBody string
432 | wantFooter string
433 | }{
434 | {"simple message", ccfg, NewCommitMessage("feat", "", "something", "", "", ""), "feat: something", "", ""},
435 | {"with issue", ccfg, NewCommitMessage("feat", "", "something", "", "JIRA-123", ""), "feat: something", "", "jira: JIRA-123"},
436 | {"with issue using hash", ccfgHash, NewCommitMessage("feat", "", "something", "", "JIRA-123", ""), "feat: something", "", "jira #JIRA-123"},
437 | {"with issue using double hash", ccfgHash, NewCommitMessage("feat", "", "something", "", "#JIRA-123", ""), "feat: something", "", "jira #JIRA-123"},
438 | {"with breaking change", ccfg, NewCommitMessage("feat", "", "something", "", "", "breaks"), "feat: something", "", "BREAKING CHANGE: breaks"},
439 | {"with scope", ccfg, NewCommitMessage("feat", "scope", "something", "", "", ""), "feat(scope): something", "", ""},
440 | {"with body", ccfg, NewCommitMessage("feat", "", "something", "body", "", ""), "feat: something", "body", ""},
441 | {"with multiline body", ccfg, NewCommitMessage("feat", "", "something", multilineBody, "", ""), "feat: something", multilineBody, ""},
442 | {"full message", ccfg, NewCommitMessage("feat", "scope", "something", multilineBody, "JIRA-123", "breaks"), "feat(scope): something", multilineBody, fullFooter},
443 | {"config without issue key", ccfgEmptyIssue, NewCommitMessage("feat", "", "something", "", "JIRA-123", ""), "feat: something", "", ""},
444 | {"with issue and issue prefix", ccfgGitIssue, NewCommitMessage("feat", "", "something", "", "123", ""), "feat: something", "", "issue: #123"},
445 | {"with #issue and issue prefix", ccfgGitIssue, NewCommitMessage("feat", "", "something", "", "#123", ""), "feat: something", "", "issue: #123"},
446 | }
447 | for _, tt := range tests {
448 | t.Run(tt.name, func(t *testing.T) {
449 | got, got1, got2 := NewMessageProcessor(tt.cfg, newBranchCfg(false)).Format(tt.msg)
450 | if got != tt.wantHeader {
451 | t.Errorf("MessageProcessorImpl.Format() header got = %v, want %v", got, tt.wantHeader)
452 | }
453 | if got1 != tt.wantBody {
454 | t.Errorf("MessageProcessorImpl.Format() body got = %v, want %v", got1, tt.wantBody)
455 | }
456 | if got2 != tt.wantFooter {
457 | t.Errorf("MessageProcessorImpl.Format() footer got = %v, want %v", got2, tt.wantFooter)
458 | }
459 | })
460 | }
461 | }
462 |
463 | var expectedBodyFullMessage = `
464 | see the issue for details
465 |
466 | on typos fixed.
467 |
468 | Reviewed-by: Z
469 | Refs #133`
470 |
471 | func Test_splitCommitMessageContent(t *testing.T) {
472 | tests := []struct {
473 | name string
474 | content string
475 | wantSubject string
476 | wantBody string
477 | }{
478 | {"single line commit", "feat: something", "feat: something", ""},
479 | {"multi line commit", fullMessage, "fix: correct minor typos in code", expectedBodyFullMessage},
480 | }
481 | for _, tt := range tests {
482 | t.Run(tt.name, func(t *testing.T) {
483 | got, got1 := splitCommitMessageContent(tt.content)
484 | if got != tt.wantSubject {
485 | t.Errorf("splitCommitMessageContent() subject got = %v, want %v", got, tt.wantSubject)
486 | }
487 | if got1 != tt.wantBody {
488 | t.Errorf("splitCommitMessageContent() body got1 = [%v], want [%v]", got1, tt.wantBody)
489 | }
490 | })
491 | }
492 | }
493 |
494 | func Test_parseSubjectMessage(t *testing.T) {
495 | tests := []struct {
496 | name string
497 | message string
498 | wantType string
499 | wantScope string
500 | wantDescription string
501 | wantHasBreakingChange bool
502 | }{
503 | {"valid commit", "feat: something", "feat", "", "something", false},
504 | {"valid commit with scope", "feat(scope): something", "feat", "scope", "something", false},
505 | {"valid commit with breaking change", "feat(scope)!: something", "feat", "scope", "something", true},
506 | {"missing description", "feat: ", "feat", "", "", false},
507 | }
508 | for _, tt := range tests {
509 | t.Run(tt.name, func(t *testing.T) {
510 | ctype, scope, description, hasBreakingChange := parseSubjectMessage(tt.message)
511 | if ctype != tt.wantType {
512 | t.Errorf("parseSubjectMessage() type got = %v, want %v", ctype, tt.wantType)
513 | }
514 | if scope != tt.wantScope {
515 | t.Errorf("parseSubjectMessage() scope got = %v, want %v", scope, tt.wantScope)
516 | }
517 | if description != tt.wantDescription {
518 | t.Errorf("parseSubjectMessage() description got = %v, want %v", description, tt.wantDescription)
519 | }
520 | if hasBreakingChange != tt.wantHasBreakingChange {
521 | t.Errorf("parseSubjectMessage() hasBreakingChange got = %v, want %v", hasBreakingChange, tt.wantHasBreakingChange)
522 | }
523 | })
524 | }
525 | }
526 |
527 | func Test_prepareHeader(t *testing.T) {
528 | tests := []struct {
529 | name string
530 | headerSelector string
531 | commitHeader string
532 | wantHeader string
533 | wantError bool
534 | }{
535 | {"conventional without selector", "", "feat: something", "feat: something", false},
536 | {"conventional with scope without selector", "", "feat(scope): something", "feat(scope): something", false},
537 | {"non-conventional without selector", "", "something", "something", false},
538 | {"matching conventional with selector with group", "Merged PR (\\d+): (?P.*)", "Merged PR 123: feat: something", "feat: something", false},
539 | {"matching non-conventional with selector with group", "Merged PR (\\d+): (?P.*)", "Merged PR 123: something", "something", false},
540 | {"matching non-conventional with selector without group", "Merged PR (\\d+): (.*)", "Merged PR 123: something", "", true},
541 | {"non-matching non-conventional with selector with group", "Merged PR (\\d+): (?P.*)", "something", "", true},
542 | {"matching non-conventional with invalid regex", "Merged PR (\\d+): (?.*)", "Merged PR 123: something", "", true},
543 | }
544 | for _, tt := range tests {
545 | t.Run(tt.name, func(t *testing.T) {
546 | msgProcessor := NewMessageProcessor(newCommitMessageCfg(tt.headerSelector), newBranchCfg(false))
547 | header, err := msgProcessor.prepareHeader(tt.commitHeader)
548 |
549 | if tt.wantError && err == nil {
550 | t.Errorf("prepareHeader() err got = %v, want not nil", err)
551 | }
552 | if header != tt.wantHeader {
553 | t.Errorf("prepareHeader() header got = %v, want %v", header, tt.wantHeader)
554 | }
555 | })
556 | }
557 | }
558 |
559 | func Test_removeCarriage(t *testing.T) {
560 | tests := []struct {
561 | name string
562 | commit string
563 | want string
564 | }{
565 | {"normal string", "normal string", "normal string"},
566 | {"break line", "normal\nstring", "normal\nstring"},
567 | {"carriage return", "normal\r\nstring", "normal\nstring"},
568 | }
569 | for _, tt := range tests {
570 | t.Run(tt.name, func(t *testing.T) {
571 | if got := removeCarriage(tt.commit); got != tt.want {
572 | t.Errorf("removeCarriage() = %v, want %v", got, tt.want)
573 | }
574 | })
575 | }
576 | }
577 |
--------------------------------------------------------------------------------