commit af99bebf913ae94af66d6cb110e79f344032f2d1 Author: Voomra Date: Wed Jul 30 18:44:50 2025 +0300 add: import code портирован код из старого репозитория https://di9.ru/git/Voomra/Conventional-Commits diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1a85fed --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# App +target/ +!target/.gitkeep diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/go-commitlint.iml b/.idea/go-commitlint.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/go-commitlint.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..94d8235 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..83b8667 --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +#!make + +build-linux: + GOOS=linux go build -o target/commitlint cmd/commitlint/main.go + +build-windows: + GOOS=windows go build -o target/commitlint.exe cmd/commitlint/main.go + +publish-linux: + curl -XPUT --user ${GITEA_USER}:${GITEA_TOKEN} --upload-file target/commitlint ${GITEA_URL}/api/packages/${GITEA_USER}/generic/di9.go.commitlint/1.2/commitlint + +publish-windows: + curl -XPUT --user ${GITEA_USER}:${GITEA_TOKEN} --upload-file target/commitlint.exe ${GITEA_URL}/api/packages/${GITEA_USER}/generic/di9.go.commitlint/1.2/commitlint.exe \ No newline at end of file diff --git a/README.MD b/README.MD new file mode 100644 index 0000000..b806cf4 --- /dev/null +++ b/README.MD @@ -0,0 +1,51 @@ +# Commit-lint tool + +Программа на Go для проверки описаний коммитов на соответствие [соглашению о коммитах](https://di9.ru/git/Conventional-Commits/specification/src/branch/master/CONVENTIONAL_COMMITS.MD). + +## Использование + +Добавить git hook `commit-msg`: + +```shell +#!/usr/bin/env sh +commitlint -commitMessage="$1" -config="commitlint.json" +``` + +| Параметр | Описание | По-умолчанию | +|:-----------------|:--------------------------------|-----------------------| +| `-commitMessage` | Путь до файла сообщения коммита | `.git/COMMIT_EDITMSG` | +| `-config` | Путь до файла настроек | `commitlint.json` | + +### Настройка + +Настройка по-умолчанию считывается из файла `commitlint.json`, который находится в корне репозитория + +```json +{ + "types": [ "build", "docs", "pref", "refac", "revert", "style", "test" ], + "contexts": [ "git", "ide" ], + "excludes": [ "^wip$" ], + "maxLengthLine": 72 +} +``` + +| Настройка | Описание | +|:----------------|:------------------------------------------| +| `types` | Перечисление допустимых типов коммитов | +| `contexts` | Перечисление допустимых контекстов | +| `excludes` | Перечисление шаблонов для исключений | +| `maxLengthLine` | Максимальная длинна первой строки коммита | + +## Сборка + +Для Linux: + +```shell +make build-linux +``` + +Для Windows: + +```shell +make build-windows +``` diff --git a/cmd/commitlint/main.go b/cmd/commitlint/main.go new file mode 100644 index 0000000..db8e585 --- /dev/null +++ b/cmd/commitlint/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "commitlint/internal/commitlint" + "flag" +) + +func main() { + commitMessagePath := flag.String("commitMessage", ".git/COMMIT_EDITMSG", "path to commit message file") + configPath := flag.String("config", "commitlint.json", "configuration file") + flag.Parse() + + err := commitlint.EntryPoint(commitMessagePath, configPath) + if err != nil { + panic(err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..28279c9 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module commitlint + +go 1.21 + +require github.com/stretchr/testify v1.9.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..60ce688 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/commitlint/commitlint.go b/internal/commitlint/commitlint.go new file mode 100644 index 0000000..bf6d1b7 --- /dev/null +++ b/internal/commitlint/commitlint.go @@ -0,0 +1,63 @@ +package commitlint + +import ( + "bufio" + "commitlint/internal/commitlint/configuration" + "commitlint/internal/commitlint/validator" + "fmt" + "os" +) + +func EntryPoint(pCommitMessagePath *string, pConfigPath *string) error { + pConfig, err := configuration.CreateConfig(pConfigPath) + if err != nil { + return err + } + + commitMessage, err := ReadCommitMessage(*pCommitMessagePath) + if err != nil { + return err + } + + pResult := validator.Validate(commitMessage, *pConfig) + if pResult != nil { + var errMessage string + + switch pResult.Result { + case validator.IncorrectPattern: + errMessage = "Incorrect commit message format" + case validator.UnknownType: + errMessage = fmt.Sprintf("Unknown commit type '%s'", pResult.UnknownType) + case validator.UnknownContext: + errMessage = fmt.Sprintf("Unknown commit context '%s'", pResult.UnknownContext) + case validator.OverlongLine: + errMessage = fmt.Sprintf("Line number %d exceeds the allowed length", pResult.Line) + case validator.BlankLine: + errMessage = "Use blank line before BODY" + } + + _, err := fmt.Fprintln(os.Stderr, errMessage) + return err + } + + return nil +} + +func ReadCommitMessage(path string) ([]string, error) { + var lines []string + + file, err := os.Open(path) + if err != nil { + panic(err) + } + defer func(file *os.File) { + _ = file.Close() + }(file) + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + + return lines, err +} diff --git a/internal/commitlint/commitlint_test.go b/internal/commitlint/commitlint_test.go new file mode 100644 index 0000000..73d034e --- /dev/null +++ b/internal/commitlint/commitlint_test.go @@ -0,0 +1,35 @@ +package commitlint + +import ( + "github.com/stretchr/testify/assert" + "os" + "testing" +) + +func TestReadCommitMessage(t *testing.T) { + createTmpFile := func() string { + tmpFile, err := os.CreateTemp("", "COMMIT_EDITMSG") + if err != nil { + panic(err) + } + defer func(tmpFile *os.File) { + _ = tmpFile.Close() + }(tmpFile) + + _, err = tmpFile.WriteString("Line 1\nLine 2\nLine 3") + if err != nil { + panic(err) + } + + return tmpFile.Name() + } + + exceptedContent := []string{"Line 1", "Line 2", "Line 3"} + + message, err := ReadCommitMessage(createTmpFile()) + if err != nil { + panic(err) + } + + assert.Equal(t, exceptedContent, message) +} diff --git a/internal/commitlint/configuration/config.go b/internal/commitlint/configuration/config.go new file mode 100644 index 0000000..ddbd8a1 --- /dev/null +++ b/internal/commitlint/configuration/config.go @@ -0,0 +1,105 @@ +package configuration + +import ( + "encoding/json" + "io" + "os" + "regexp" +) + +type UserConfig struct { + Types *[]string `json:"types"` + Contexts *[]string `json:"contexts"` + Excludes *[]string `json:"excludes"` + MaxLengthLine *int `json:"maxLengthLine"` +} + +type Config struct { + Types []string + Contexts []string + Excludes []*regexp.Regexp + MaxLengthLine int +} + +func CreateConfig(pPath *string) (*Config, error) { + config := Config{ + Types: []string{"fix", "feat"}, + Contexts: []string{}, + Excludes: []*regexp.Regexp{}, + MaxLengthLine: 72, + } + + if pPath != nil && isFileExists(*pPath) { + userConfig, err := readUserConfig(*pPath) + if err != nil { + return nil, err + } + + mergeConfigs(&config, userConfig) + } + + return &config, nil +} + +func isFileExists(path string) bool { + info, err := os.Stat(path) + if os.IsNotExist(err) { + return false + } + return !info.IsDir() +} + +func readUserConfig(path string) (*UserConfig, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer func(fileJson *os.File) { + err := fileJson.Close() + if err != nil { + panic(err) + } + }(file) + + bytes, err := io.ReadAll(file) + if err != nil { + return nil, err + } + + var userConfig UserConfig + err = json.Unmarshal(bytes, &userConfig) + if err != nil { + return nil, err + } + + return &userConfig, nil +} + +func mergeConfigs(config *Config, userConfig *UserConfig) { + if userConfig.Types != nil { + for _, item := range *(*userConfig).Types { + config.Types = append(config.Types, item) + } + } + + if userConfig.Contexts != nil { + for _, item := range *(userConfig.Contexts) { + config.Contexts = append(config.Contexts, item) + } + } + + if userConfig.Excludes != nil { + for _, item := range *(userConfig.Excludes) { + pattern, err := regexp.Compile("(?i)" + item) + if err != nil { + panic(err) + } + + config.Excludes = append(config.Excludes, pattern) + } + } + + if userConfig.MaxLengthLine != nil { + config.MaxLengthLine = *(userConfig.MaxLengthLine) + } +} diff --git a/internal/commitlint/configuration/config_test.go b/internal/commitlint/configuration/config_test.go new file mode 100644 index 0000000..1ddc417 --- /dev/null +++ b/internal/commitlint/configuration/config_test.go @@ -0,0 +1,69 @@ +package configuration + +import ( + "github.com/stretchr/testify/assert" + "os" + "regexp" + "testing" +) + +func TestReadUserConfig(t *testing.T) { + createTmpJson := func() string { + tmpFile, err := os.CreateTemp("", "config.json") + if err != nil { + panic(err) + } + defer func(tmpFile *os.File) { + _ = tmpFile.Close() + }(tmpFile) + + _, err = tmpFile.WriteString(`{"types": ["type1", "type2"], "contexts": ["ctx1", "ctx2"], "excludes": ["^wip$"], "maxLengthLine": 14}`) + if err != nil { + panic(err) + } + + return tmpFile.Name() + } + + expectedUserConfig := UserConfig{} + expectedUserConfig.Types = &([]string{"type1", "type2"}) + expectedUserConfig.Contexts = &([]string{"ctx1", "ctx2"}) + expectedUserConfig.Excludes = &([]string{"^wip$"}) + expectedUserConfig.MaxLengthLine = new(int) + *(expectedUserConfig.MaxLengthLine) = 14 + + userConfig, err := readUserConfig(createTmpJson()) + if err != nil { + panic(err) + } + + assert.Equal(t, expectedUserConfig, *userConfig) +} + +func TestMergeConfigs(t *testing.T) { + config := Config{ + Types: []string{"fix", "feat"}, + Contexts: []string{}, + Excludes: []*regexp.Regexp{}, + MaxLengthLine: 72, + } + + userConfig := UserConfig{} + userConfig.Types = &([]string{"type1", "type2"}) + userConfig.Contexts = &([]string{"ctx1", "ctx2"}) + userConfig.Excludes = &([]string{"^wip$"}) + userConfig.MaxLengthLine = new(int) + *(userConfig.MaxLengthLine) = 14 + + expectedConfig := Config{ + Types: []string{"fix", "feat", "type1", "type2"}, + Contexts: []string{"ctx1", "ctx2"}, + Excludes: []*regexp.Regexp{}, + MaxLengthLine: 14, + } + expectedConfig.Excludes = append(expectedConfig.Excludes, regexp.MustCompile("^wip$")) + + mergeConfigs(&config, &userConfig) + + assert.Equal(t, expectedConfig, config) +} diff --git a/internal/commitlint/validator/validator.go b/internal/commitlint/validator/validator.go new file mode 100644 index 0000000..63d6ccb --- /dev/null +++ b/internal/commitlint/validator/validator.go @@ -0,0 +1,90 @@ +package validator + +import ( + "commitlint/internal/commitlint/configuration" + "regexp" + "slices" +) + +type Result int + +const ( + IncorrectPattern Result = iota + UnknownType + UnknownContext + OverlongLine + BlankLine +) + +type ValidateError struct { + // Тип проблемы + Result Result + + // Проблемная строка в коммите. Начинается с 1 + Line int + + // Переданный не известный тип + UnknownType string + + // Переданный не известный контекст + UnknownContext string +} + +func Validate(commitMessage []string, config configuration.Config) *ValidateError { + if pResult := validateFirstLine(commitMessage[0], config); pResult != nil { + return pResult + } + + if len(commitMessage) > 1 { + if pResult := validateBody(commitMessage[1:], config); pResult != nil { + return pResult + } + } + + return nil +} + +func validateFirstLine(line string, config configuration.Config) *ValidateError { + for _, pExclude := range config.Excludes { + if pExclude.MatchString(line) { + return nil + } + } + + commitFirstLine := regexp.MustCompile("^([a-z0-9]+?)!?(?:\\(([a-z0-9]+?)\\))?: .+$") + match := commitFirstLine.FindAllStringSubmatch(line, -1) + + if len(match) == 0 { + return &ValidateError{Result: IncorrectPattern, Line: 1} + } + + commitType := match[0][1] + if !slices.Contains(config.Types, commitType) { + return &ValidateError{Result: UnknownType, Line: 1, UnknownType: commitType} + } + + commitContext := match[0][2] + if len(commitContext) > 0 && !slices.Contains(config.Contexts, commitContext) { + return &ValidateError{Result: UnknownContext, Line: 1, UnknownContext: commitContext} + } + + if len(line) > config.MaxLengthLine { + return &ValidateError{Result: OverlongLine, Line: 1} + } + + return nil +} + +func validateBody(body []string, config configuration.Config) *ValidateError { + if len(body[0]) > 0 { + return &ValidateError{Result: BlankLine, Line: 2} + } + + for i, line := range body[1:] { + if len(line) > config.MaxLengthLine { + return &ValidateError{Result: OverlongLine, Line: i + 3} + } + } + + return nil +} diff --git a/internal/commitlint/validator/validator_test.go b/internal/commitlint/validator/validator_test.go new file mode 100644 index 0000000..21dcbd3 --- /dev/null +++ b/internal/commitlint/validator/validator_test.go @@ -0,0 +1,83 @@ +package validator + +import ( + "commitlint/internal/commitlint/configuration" + "github.com/stretchr/testify/assert" + "regexp" + "testing" +) + +func TestValidateFirstLine(t *testing.T) { + pConfig, err := configuration.CreateConfig(nil) + if err != nil { + panic(err) + } + pConfig.Contexts = append(pConfig.Contexts, "git") + pConfig.Excludes = append(pConfig.Excludes, regexp.MustCompile("(?i)^wip$")) + + testTab := []struct { + line string + excepted *ValidateError + }{ + { + line: "incorrect line", + excepted: &ValidateError{Result: IncorrectPattern, Line: 1}, + }, + { + line: "zed: message", + excepted: &ValidateError{Result: UnknownType, Line: 1, UnknownType: "zed"}, + }, + { + line: "fix(zed): message", + excepted: &ValidateError{Result: UnknownContext, Line: 1, UnknownContext: "zed"}, + }, + { + line: "fix: message", + excepted: nil, + }, + { + line: "fix(git): message", + excepted: nil, + }, + { + line: "WIP", + excepted: nil, + }, + } + + for _, test := range testTab { + pResult := validateFirstLine(test.line, *pConfig) + assert.Equal(t, test.excepted, pResult) + } +} + +func TestValidateBody(t *testing.T) { + pConfig, err := configuration.CreateConfig(nil) + if err != nil { + panic(err) + } + pConfig.MaxLengthLine = 10 + + testTab := []struct { + lines []string + excepted *ValidateError + }{ + { + lines: []string{" ", "Some Body"}, + excepted: &ValidateError{Result: BlankLine, Line: 2}, + }, + { + lines: []string{"", "Some Body 1234567890"}, + excepted: &ValidateError{Result: OverlongLine, Line: 3}, + }, + { + lines: []string{"", "Some Body"}, + excepted: nil, + }, + } + + for _, test := range testTab { + pResult := validateBody(test.lines, *pConfig) + assert.Equal(t, test.excepted, pResult) + } +} diff --git a/target/.gitkeep b/target/.gitkeep new file mode 100644 index 0000000..e69de29