diff --git a/README.md b/README.md index ebf4a991..17d47011 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ policies: scopes: - "scope" descriptionLength: 72 + rejectAutoSquash: false - type: license spec: skipPaths: diff --git a/internal/policy/commit/check_conventional_commit.go b/internal/policy/commit/check_conventional_commit.go index d20ee096..1240c61f 100644 --- a/internal/policy/commit/check_conventional_commit.go +++ b/internal/policy/commit/check_conventional_commit.go @@ -19,11 +19,15 @@ type Conventional struct { Types []string `mapstructure:"types"` Scopes []string `mapstructure:"scopes"` DescriptionLength int `mapstructure:"descriptionLength"` + RejectAutoSquash bool `mapstructure:"rejectAutoSquash"` } // HeaderRegex is the regular expression used for Conventional Commits 1.0.0. var HeaderRegex = regexp.MustCompile(`^(\w*)(\(([^)]+)\))?(!)?:\s{1}(.*)($|\n{2})`) +// AutoSquashHeaderRegex matches git autosquash commit messages. +var AutoSquashHeaderRegex = regexp.MustCompile(`^(fixup|squash|amend)!\s+.+`) + const ( // TypeFeat is a commit of the type fix patches a bug in your codebase // (this correlates with MINOR in semantic versioning). @@ -62,7 +66,19 @@ func (c ConventionalCommitCheck) Errors() []error { // ValidateConventionalCommit returns the commit type. func (c Commit) ValidateConventionalCommit() policy.Check { //nolint:ireturn check := &ConventionalCommitCheck{} - groups := parseHeader(c.msg) + header := firstHeaderLine(c.msg) + + if isAutoSquashMessage(header) { + if !c.Conventional.RejectAutoSquash { + return check + } + + check.errors = append(check.errors, errors.Errorf("Auto-squash commit format is disabled: %q", c.msg)) + + return check + } + + groups := parseHeader(header) if len(groups) != 7 { check.errors = append(check.errors, errors.Errorf("Invalid conventional commits format: %q", c.msg)) @@ -124,12 +140,17 @@ func (c Commit) ValidateConventionalCommit() policy.Check { //nolint:ireturn return check } -func parseHeader(msg string) []string { - // To circumvent any policy violation due to the leading \n that GitHub - // prefixes to the commit message on a squash merge, we remove it from the - // message. - header := strings.Split(strings.TrimPrefix(msg, "\n"), "\n")[0] +func parseHeader(header string) []string { groups := HeaderRegex.FindStringSubmatch(header) return groups } + +func isAutoSquashMessage(header string) bool { + return AutoSquashHeaderRegex.MatchString(header) +} + +func firstHeaderLine(msg string) string { + // GitHub can prefix squash-merge commit messages with a leading newline. + return strings.Split(strings.TrimPrefix(msg, "\n"), "\n")[0] +} diff --git a/internal/policy/commit/commit_test.go b/internal/policy/commit/commit_test.go index d71417c0..a0b62b50 100644 --- a/internal/policy/commit/commit_test.go +++ b/internal/policy/commit/commit_test.go @@ -22,6 +22,7 @@ func TestConventionalCommitPolicy(t *testing.T) { Name string CreateCommit func(t *testing.T) error ExpectValid bool + Conventional *Conventional } for _, test := range []testDesc{ @@ -60,6 +61,27 @@ func TestConventionalCommitPolicy(t *testing.T) { CreateCommit: createInvalidEmptyCommit, ExpectValid: false, }, + { + Name: "Fixup", + CreateCommit: createFixupCommit, + ExpectValid: true, + }, + { + Name: "FixupRejected", + CreateCommit: createFixupCommit, + ExpectValid: false, + Conventional: rejectAutoSquashConventional(), + }, + { + Name: "Squash", + CreateCommit: createSquashCommit, + ExpectValid: true, + }, + { + Name: "Amend", + CreateCommit: createAmendCommit, + ExpectValid: true, + }, } { func(test testDesc) { t.Run(test.Name, func(tt *testing.T) { @@ -77,7 +99,7 @@ func TestConventionalCommitPolicy(t *testing.T) { tt.Error(err) } - report, err := runCompliance() + report, err := runComplianceWithConventional(test.Conventional) if err != nil { t.Error(err) } @@ -363,16 +385,30 @@ func runComplianceRange(id1, id2 string) (*policy.Report, error) { } func runCompliance() (*policy.Report, error) { - c := &Commit{ - Conventional: &Conventional{ + return runComplianceWithConventional(nil) +} + +func runComplianceWithConventional(conventional *Conventional) (*policy.Report, error) { + if conventional == nil { + conventional = &Conventional{ Types: []string{"type"}, Scopes: []string{"scope", "^valid"}, - }, + } } + c := &Commit{Conventional: conventional} + return c.Compliance(&policy.Options{}) } +func rejectAutoSquashConventional() *Conventional { + return &Conventional{ + Types: []string{"type"}, + Scopes: []string{"scope", "^valid"}, + RejectAutoSquash: true, + } +} + func initRepo(t *testing.T) error { _, err := exec.CommandContext(t.Context(), "git", "init").Output() if err != nil { @@ -442,3 +478,21 @@ func createInvalidCommitRegex(t *testing.T) error { return err } + +func createFixupCommit(t *testing.T) error { + _, err := exec.CommandContext(t.Context(), "git", "-c", "user.name='test'", "-c", "user.email='test@siderolabs.io'", "commit", "-m", "fixup! deadbeef").Output() + + return err +} + +func createSquashCommit(t *testing.T) error { + _, err := exec.CommandContext(t.Context(), "git", "-c", "user.name='test'", "-c", "user.email='test@siderolabs.io'", "commit", "-m", "squash! deadbeef").Output() + + return err +} + +func createAmendCommit(t *testing.T) error { + _, err := exec.CommandContext(t.Context(), "git", "-c", "user.name='test'", "-c", "user.email='test@siderolabs.io'", "commit", "-m", "amend! deadbeef").Output() + + return err +}