diff --git a/README.md b/README.md index ebf4a991..c781d19c 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ policies: scopes: - "scope" descriptionLength: 72 + acceptAutoSquash: 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..2b509e07 100644 --- a/internal/policy/commit/check_conventional_commit.go +++ b/internal/policy/commit/check_conventional_commit.go @@ -19,11 +19,17 @@ type Conventional struct { Types []string `mapstructure:"types"` Scopes []string `mapstructure:"scopes"` DescriptionLength int `mapstructure:"descriptionLength"` + AcceptAutoSquash bool `mapstructure:"acceptAutoSquash"` } // 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 produced by +// `git commit --fixup=` / `--squash=` and (since Git 2.32) +// `--fixup=amend:` / `--fixup=reword:`. +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 +68,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.AcceptAutoSquash { + return check + } + + check.errors = append(check.errors, errors.Errorf("auto-squash commits are rejected by policy: %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 +142,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..6e16f2ab 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,29 @@ func TestConventionalCommitPolicy(t *testing.T) { CreateCommit: createInvalidEmptyCommit, ExpectValid: false, }, + { + Name: "FixupRejected", + CreateCommit: createFixupCommit, + ExpectValid: false, + }, + { + Name: "FixupAccepted", + CreateCommit: createFixupCommit, + ExpectValid: true, + Conventional: acceptAutoSquashConventional(), + }, + { + Name: "SquashAccepted", + CreateCommit: createSquashCommit, + ExpectValid: true, + Conventional: acceptAutoSquashConventional(), + }, + { + Name: "AmendAccepted", + CreateCommit: createAmendCommit, + ExpectValid: true, + Conventional: acceptAutoSquashConventional(), + }, } { func(test testDesc) { t.Run(test.Name, func(tt *testing.T) { @@ -77,7 +101,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 +387,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 acceptAutoSquashConventional() *Conventional { + return &Conventional{ + Types: []string{"type"}, + Scopes: []string{"scope", "^valid"}, + AcceptAutoSquash: true, + } +} + func initRepo(t *testing.T) error { _, err := exec.CommandContext(t.Context(), "git", "init").Output() if err != nil { @@ -442,3 +480,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 +}