diff --git a/README.md b/README.md index 3bcda66..7431c76 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,10 @@ Fixes some issues of commitizen-go and supports more new features. ## Features -- Multi-template support -- Support more options of `git commit` -- Use [bubbletea](https://github.com/charmbracelet/bubbletea) instead of [survey](https://github.com/AlecAivazis/survey) ([survey](https://github.com/AlecAivazis/survey) is no longer maintained). -- Better unit tests. +- Multi-template support. +- More powerful and flexible template. +- Support more options of `git commit`. +- Use [huh](https://github.com/charmbracelet/huh) instead of [survey](https://github.com/AlecAivazis/survey) ([survey](https://github.com/AlecAivazis/survey) is no longer maintained). ## Getting Started @@ -72,6 +72,7 @@ $ git cz Download the pre-compiled binaries from the [releases page](https://github.com/shipengqi/commitizen/releases) and copy them to the desired location. Then install this tool to git-core as git-cz: + ``` $ commitizen init ``` @@ -97,79 +98,203 @@ $ make && ./_output/$(GOOS)/$(GOARCH)/bin/commitizen init ## Configuration -You can set configuration file that `.git-czrc` at repository root or home directory. The configuration file that located in repository root have a priority over the one in home directory. The format is the same as the following: +You can set configuration file that `.czrc` at repository root or home directory. The configuration file that located in repository root have a priority over the one in home directory. The format is the same as the following: ```yaml name: my-default default: true # (optional) If true, this template will be used as the default template, note that there can only be one default template items: - name: type - desc: "Select the type of change that you're committing:" - type: select + group: page1 + label: "Select the type of change that you're committing:" + type: list options: - - name: feat - desc: "A new feature" - - name: fix - desc: "A bug fix" - - name: docs - desc: "Documentation only changes" - - name: test - desc: "Adding missing tests" - - name: WIP - desc: "Work in progress" - - name: chore - desc: "Changes to the build process or auxiliary tools\n and libraries such as documentation generation" - - name: style - desc: "Changes that do not affect the meaning of the code\n (white-space, formatting, missing semi-colons, etc)" - - name: refactor - desc: "A code change that neither fixes a bug nor adds a feature" - - name: perf - desc: "A code change that improves performance" - - name: revert - desc: "Revert to a commit" + - value: feat + key: "feat: A new feature" + - value: fix + key: "fix: A bug fix" + - value: docs + key: "docs: Documentation only changes" + - value: test + key: "test: Adding missing or correcting existing tests" + - value: chore + key: "chore: Changes to the build process or auxiliary tools and\n libraries such as documentation generation" + - value: style + key: "style: Changes that do not affect the meaning of the code\n (white-space, formatting, missing semi-colons, etc)" + - value: refactor + key: "refactor: A code change that neither fixes a bug nor adds a feature" + - value: perf + key: "perf: A code change that improves performance" + - value: revert + key: "revert: Reverts a previous commit" - name: scope - desc: "Scope. Could be anything specifying place of the commit change:" - type: input + group: page2 + label: "Scope. What is the scope of this change? (class or file name):" + type: string + regex: ^[a-zA-Z0-9]+ - name: subject - desc: "Subject. Concise description of the changes. Imperative, lower case and no final dot:" - type: input - required: true # (optional) If true, enable a validator that requires the control have a non-empty value. + group: page2 + label: "Subject. Write a short and imperative summary of the code change (lower case and no period):" + type: string + required: true - name: body - desc: "Body. Motivation for the change and contrast this with previous behavior:" - type: textarea + group: page3 + label: "Body. Provide additional contextual information about the code changes:" + type: text - name: footer - desc: "Footer. Information about Breaking Changes and reference issues that this commit closes:" - type: textarea + group: page3 + label: "Footer. Information about Breaking Changes and reference issues that this commit closes:" + type: text format: "{{.type}}{{with .scope}}({{.}}){{end}}: {{.subject}}{{with .body}}\n\n{{.}}{{end}}{{with .footer}}\n\n{{.}}{{end}}"` ``` +### Format + Commit message `format`: ``` format: "{{.type}}{{with .scope}}({{.}}){{end}}: {{.subject}}{{with .body}}\n\n{{.}}{{end}}{{with .footer}}\n\n{{.}}{{end}}" ``` +### Items + +#### Common Item Properties + +| Property | Required | Default Value | Description | +|:------------|:---------|:--------------|:--------------------------------------------------------------------------------------------------------------------| +| name | yes | - | Unique identifier for the item. | +| label | yes | - | This will be used as the label for the input field in the UI. | +| type | yes | - | The type of item. Determines which UI widget is shown. See the Item Types section to see all the different options. | +| group | no | - | The name of the group this item belongs to. | +| description | no | - | A short description of the item for user guidance. This will be displayed along with the input field. | + +#### Item Types + +- string +- text +- integer +- boolean +- secret +- list +- multi_list + +#### string + +`string` are single line text parameters. + +Properties: + +| Property | Required | Default Value | Description | +|:--------------|:---------|:--------------|:-------------------------------------------------------------------------------------------------------------| +| required | no | `false` | Whether a string value is required or not. | +| fqdn | no | `false` | Add a preset FQDN regex to validate string. | +| ip | no | `false` | Add a preset IPv4/IPv6 regex to validate string. | +| trim | no | `false` | If true, will remove the leading and trailing blank characters before submit. | +| default_value | no | - | The default value for this item. | +| regex | no | - | A regex used to validate the string. | +| min_length | no | - | The minimum length of the string. If the value is not required and no value has been given, this is ignored. | +| max_length | no | - | The maximum length of the string. | + +#### text + +Properties: + +| Property | Required | Default Value | Description | +|:--------------|:---------|:--------------|:-----------------------------------------------------------------------------------------------------------| +| required | no | `false` | Whether the text is required or not. | +| height | no | 5 | The height of the text. | +| default_value | no | - | The default value for this item. | +| regex | no | - | A regex used to validate the text. | +| min_length | no | - | The minimum length of the text. If the value is not required and no value has been given, this is ignored. | +| max_length | no | - | The maximum length of the text. | + +#### integer + +`integer` is a number. + +Properties: + +| Property | Required | Default Value | Description | +|:--------------|:---------|:--------------|:----------------------------------------| +| required | no | `false` | Whether the integer is required or not. | +| default_value | no | - | The default value for this item. | +| min | no | - | The minimum value allowed. | +| max | no | - | The maximum value allowed. | + +#### boolean + +`boolean` are true or false values. + +Properties: + +| Property | Required | Default Value | Description | +|:--------------|:---------|:--------------|:---------------------------------| +| default_value | no | - | The default value for this item. | + +#### secret + +`secret` is used for sensitive data that should not be echoed in the UI, for example, passwords. + +Properties: + +| Property | Required | Default Value | Description | +|:--------------|:---------|:--------------|:-------------------------------------------------------------------------------------------------------------| +| required | no | `false` | Whether the secret is required or not. | +| trim | no | `false` | If true, will remove the leading and trailing blank characters before submit. | +| default_value | no | - | The default value for this item. | +| regex | no | - | A regex used to validate the secret. | +| min_length | no | - | The minimum length of the secret. If the value is not required and no value has been given, this is ignored. | +| max_length | no | - | The maximum length of the secret. | + +#### list + +`list` is predefined lists of values that can be picked by the user. + +Properties: + +| Property | Required | Default Value | Description | +|:--------------|:---------|:--------------|:-------------------------------------------| +| required | no | `false` | Whether a string value is required or not. | +| default_value | no | - | The default value for this item. | +| options | yes | - | The list of options to choose from. | + +#### multi_list + +Similar to `list`, but with multiple selection. + +Properties: + +| Property | Required | Default Value | Description | +|:--------------|:---------|:--------------|:-------------------------------------------| +| required | no | `false` | Whether a string value is required or not. | +| default_value | no | - | A list of default selection values. | +| options | yes | - | The list of options to choose from. | +| limit | no | `false` | The limit of the multiple selection list. | + +#### list/multi_list Options + +Properties: + +| Property | Required | Description | +|:---------|:---------|:---------------------------------| +| key | yes | The message shown in the UI. | +| value | yes | Unique identifier for the value. | + ### Multiple Templates -You can define multiple templates in the `.git-czrc` file, separated by `---`: +You can define multiple templates in the `.czrc` file, separated by `---`: ```yaml name: angular-template items: - - name: scope - desc: "Scope. Could be anything specifying place of the commit change:" - type: input - # ... +# ... format: "{{.type}}{{with .scope}}({{.}}){{end}}: {{.subject}}{{with .body}}\n\n{{.}}{{end}}{{with .footer}}\n\n{{.}}{{end}}"` --- name: my-template items: - - name: scope - desc: "Scope. Could be anything specifying place of the commit change:" - type: input - # ... +# ... format: "{{.type}}{{with .scope}}({{.}}){{end}}: {{.subject}}{{with .body}}\n\n{{.}}{{end}}{{with .footer}}\n\n{{.}}{{end}}"` ``` diff --git a/internal/config/default.go b/internal/config/default.go index 4e98067..25d2257 100644 --- a/internal/config/default.go +++ b/internal/config/default.go @@ -1,7 +1,6 @@ package config const DefaultCommitTemplate = `--- -version: v2 name: default default: true items: @@ -32,11 +31,13 @@ items: group: page2 label: "Scope. What is the scope of this change? (class or file name):" type: string + trim: true - name: subject group: page2 label: "Subject. Write a short and imperative summary of the code change (lower case and no period):" type: string required: true + trim: true - name: body group: page3 label: "Body. Provide additional contextual information about the code changes:" diff --git a/internal/errors/missing.go b/internal/errors/missing.go index e75e356..4260827 100644 --- a/internal/errors/missing.go +++ b/internal/errors/missing.go @@ -11,7 +11,7 @@ func (e MissingErr) Error() string { if e.name == "" { return fmt.Sprintf("missing required field `%s`", e.field) } - return fmt.Sprintf("'%s' missing required field: %s", e.name, e.field) + return fmt.Sprintf("item '%s' missing required field: %s", e.name, e.field) } func NewMissingErr(field string, name ...string) error { diff --git a/internal/parameter/boolean/bool.go b/internal/parameter/boolean/bool.go index 832e0e5..2bde03f 100644 --- a/internal/parameter/boolean/bool.go +++ b/internal/parameter/boolean/bool.go @@ -12,7 +12,7 @@ type Param struct { DefaultValue bool `yaml:"default_value" json:"default_value" mapstructure:"default_value"` } -func (p Param) Render() huh.Field { +func (p *Param) Render() { param := huh.NewConfirm().Key(p.Name). Title(p.Label) @@ -22,5 +22,5 @@ func (p Param) Render() huh.Field { param.Value(&p.DefaultValue) - return param + p.Field = param } diff --git a/internal/parameter/integer/int.go b/internal/parameter/integer/int.go index 4b744f7..fd0b356 100644 --- a/internal/parameter/integer/int.go +++ b/internal/parameter/integer/int.go @@ -16,7 +16,7 @@ type Param struct { Max *int `yaml:"max" json:"max" mapstructure:"max"` } -func (p Param) Render() huh.Field { +func (p *Param) Render() { param := huh.NewInput().Key(p.Name). Title(p.Label) @@ -40,5 +40,5 @@ func (p Param) Render() huh.Field { if len(group) > 0 { param.Validate(validators.Group(group...)) } - return param + p.Field = param } diff --git a/internal/parameter/list/list.go b/internal/parameter/list/list.go index 2cb198e..3c84179 100644 --- a/internal/parameter/list/list.go +++ b/internal/parameter/list/list.go @@ -16,7 +16,7 @@ type Param struct { Required bool `yaml:"required" json:"required" mapstructure:"required"` } -func (p Param) Validate() []error { +func (p *Param) Validate() []error { errs := p.Parameter.Validate() if len(p.Options) < 1 { errs = append(errs, errors.NewMissingErr("options", p.Name)) @@ -24,7 +24,7 @@ func (p Param) Validate() []error { return errs } -func (p Param) Render() huh.Field { +func (p *Param) Render() { param := huh.NewSelect[string]().Key(p.Name). Options(p.Options...). Title(p.Label) @@ -42,5 +42,5 @@ func (p Param) Render() huh.Field { if len(group) > 0 { param.Validate(validators.Group(group...)) } - return param + p.Field = param } diff --git a/internal/parameter/multilist/list.go b/internal/parameter/multilist/list.go index 6ca2c1d..13f1c4d 100644 --- a/internal/parameter/multilist/list.go +++ b/internal/parameter/multilist/list.go @@ -17,7 +17,7 @@ type Param struct { Limit *int `yaml:"limit" json:"limit" mapstructure:"limit"` } -func (p Param) Validate() []error { +func (p *Param) Validate() []error { errs := p.Parameter.Validate() if len(p.Options) < 1 { errs = append(errs, errors.NewMissingErr("options", p.Name)) @@ -25,7 +25,7 @@ func (p Param) Validate() []error { return errs } -func (p Param) Render() huh.Field { +func (p *Param) Render() { param := huh.NewMultiSelect[string]().Key(p.Name). Options(p.Options...). Title(p.Label) @@ -47,5 +47,5 @@ func (p Param) Render() huh.Field { param.Validate(validators.Group(group...)) } - return param + p.Field = param } diff --git a/internal/parameter/param.go b/internal/parameter/param.go index 72ea6ae..e43fa48 100644 --- a/internal/parameter/param.go +++ b/internal/parameter/param.go @@ -8,12 +8,14 @@ import ( ) type Interface interface { + huh.Field GetGroup() string - Render() huh.Field + Render() Validate() []error } type Parameter struct { + huh.Field `mapstructure:"-"` Name string `yaml:"name" json:"name" mapstructure:"name"` Group string `yaml:"group" json:"group" mapstructure:"group"` Label string `yaml:"label" json:"label" mapstructure:"label"` @@ -22,15 +24,13 @@ type Parameter struct { // DependsOn DependsOn `yaml:"depends_on" json:"depends_on" mapstructure:"depends_on"` } -func (p Parameter) GetGroup() string { +func (p *Parameter) GetGroup() string { return p.Group } -func (p Parameter) Render() huh.Field { - return nil -} +func (p *Parameter) Render() {} -func (p Parameter) Validate() []error { +func (p *Parameter) Validate() []error { var errs []error if strutil.IsEmpty(p.Name) { errs = append(errs, errors.NewMissingErr("parameter.name")) diff --git a/internal/parameter/secret/secret.go b/internal/parameter/secret/secret.go index 89c6e90..171774e 100644 --- a/internal/parameter/secret/secret.go +++ b/internal/parameter/secret/secret.go @@ -1,17 +1,36 @@ package secret import ( - "github.com/charmbracelet/huh" - "github.com/shipengqi/commitizen/internal/parameter/str" + "github.com/shipengqi/commitizen/internal/parameter/validators" ) type Param struct { str.Param `mapstructure:",squash"` } -func (p Param) Render() huh.Field { - input := p.Param.RenderInput() - input.Password(true) - return input +func (p *Param) Render() { + param := p.Param.RenderInput() + param.Password(true) + + // reset validators of the secret + var group []validators.Validator[string] + if p.Required { + group = append(group, validators.Required(p.Name, p.Trim)) + } + // if the value is not required and no value has been given, min length validator should be ignored. + if p.Required && p.MinLength != nil { + group = append(group, validators.MinLength(*p.MinLength)) + } + if p.MaxLength != nil { + group = append(group, validators.MaxLength(*p.MaxLength)) + } + if p.Regex != "" { + group = append(group, validators.RegexValidator(p.Regex)) + } + if len(group) > 0 { + param.Validate(validators.Group(group...)) + } + + p.Field = param } diff --git a/internal/parameter/str/str.go b/internal/parameter/str/str.go index 889565a..b6d7f95 100644 --- a/internal/parameter/str/str.go +++ b/internal/parameter/str/str.go @@ -1,6 +1,8 @@ package str import ( + "strings" + "github.com/charmbracelet/huh" "github.com/shipengqi/commitizen/internal/parameter" @@ -16,16 +18,26 @@ type Param struct { Trim bool `yaml:"trim" json:"trim" mapstructure:"trim"` DefaultValue string `yaml:"default_value" json:"default_value" mapstructure:"default_value"` Regex string `yaml:"regex" json:"regex" mapstructure:"regex"` - RegexMessage string `yaml:"regex_message" json:"regex_message" mapstructure:"regex_message"` MinLength *int `yaml:"min_length" json:"min_length" mapstructure:"min_length"` MaxLength *int `yaml:"max_length" json:"max_length" mapstructure:"max_length"` } -func (p Param) Render() huh.Field { - return p.RenderInput() +func (p *Param) Render() { + p.Field = p.RenderInput() +} + +func (p *Param) GetValue() any { + if !p.Trim { + return p.Field.GetValue() + } + val := p.Field.GetValue() + if str, ok := val.(string); ok { + return strings.TrimSpace(str) + } + return p.Field.GetValue() } -func (p Param) RenderInput() *huh.Input { +func (p *Param) RenderInput() *huh.Input { param := huh.NewInput().Key(p.Name). Title(p.Label) @@ -39,7 +51,8 @@ func (p Param) RenderInput() *huh.Input { if p.Required { group = append(group, validators.Required(p.Name, p.Trim)) } - if p.MinLength != nil { + // if the value is not required and no value has been given, min length validator should be ignored. + if p.Required && p.MinLength != nil { group = append(group, validators.MinLength(*p.MinLength)) } if p.MaxLength != nil { @@ -52,7 +65,7 @@ func (p Param) RenderInput() *huh.Input { group = append(group, validators.FQDNValidator()) } if p.Regex != "" { - group = append(group, validators.RegexValidator(p.Regex, p.RegexMessage)) + group = append(group, validators.RegexValidator(p.Regex)) } if len(group) > 0 { diff --git a/internal/parameter/text/text.go b/internal/parameter/text/text.go index 95eb8fa..2b42d03 100644 --- a/internal/parameter/text/text.go +++ b/internal/parameter/text/text.go @@ -13,13 +13,12 @@ type Param struct { Required bool `yaml:"required" json:"required" mapstructure:"required"` DefaultValue string `yaml:"default_value" json:"default_value" mapstructure:"default_value"` Regex string `yaml:"regex" json:"regex" mapstructure:"regex"` - RegexMessage string `yaml:"regex_message" json:"regex_message" mapstructure:"regex_message"` MinLength *int `yaml:"min_length" json:"min_length" mapstructure:"min_length"` MaxLength *int `yaml:"max_length" json:"max_length" mapstructure:"max_length"` Height *int `yaml:"height" json:"height" mapstructure:"height"` } -func (p Param) Render() huh.Field { +func (p *Param) Render() { param := huh.NewText().Key(p.Name). Title(p.Label) @@ -37,19 +36,20 @@ func (p Param) Render() huh.Field { if p.Required { group = append(group, validators.Required(p.Name, false)) } - if p.MinLength != nil { + // if the value is not required and no value has been given, min length validator should be ignored. + if p.Required && p.MinLength != nil { group = append(group, validators.MinLength(*p.MinLength)) } if p.MaxLength != nil { group = append(group, validators.MaxLength(*p.MaxLength)) } if p.Regex != "" { - group = append(group, validators.RegexValidator(p.Regex, p.RegexMessage)) + group = append(group, validators.RegexValidator(p.Regex)) } if len(group) > 0 { param.Validate(validators.Group(group...)) } - return param + p.Field = param } diff --git a/internal/parameter/validators/str.go b/internal/parameter/validators/str.go index ef5b84b..c551ba9 100644 --- a/internal/parameter/validators/str.go +++ b/internal/parameter/validators/str.go @@ -1,7 +1,6 @@ package validators import ( - "errors" "fmt" "net" "regexp" @@ -29,11 +28,11 @@ func MinLength(min int) func(string) error { } } -func RegexValidator(regex, message string) func(string) error { +func RegexValidator(regex string) func(string) error { return func(str string) error { re := regexp.MustCompile(regex) if !re.MatchString(str) { - return errors.New(message) + return fmt.Errorf("contents must match the regex: %s", regex) } return nil } diff --git a/internal/templates/template.go b/internal/templates/template.go index 48d8093..76f3599 100644 --- a/internal/templates/template.go +++ b/internal/templates/template.go @@ -30,7 +30,7 @@ type Template struct { Default bool Items []map[string]interface{} groups []*huh.Group - fields []huh.Field + fields []parameter.Interface } func (t *Template) Initialize() error { @@ -57,30 +57,29 @@ func (t *Template) Initialize() error { } var ( param parameter.Interface - field huh.Field group string ) switch typestr { case parameter.TypeBoolean: - param = boolean.Param{} + param = &boolean.Param{} err = mapstructure.Decode(v, ¶m) case parameter.TypeString: - param = str.Param{} + param = &str.Param{} err = mapstructure.Decode(v, ¶m) case parameter.TypeInteger: - param = integer.Param{} + param = &integer.Param{} err = mapstructure.Decode(v, ¶m) case parameter.TypeSecret: - param = secret.Param{} + param = &secret.Param{} err = mapstructure.Decode(v, ¶m) case parameter.TypeText: - param = text.Param{} + param = &text.Param{} err = mapstructure.Decode(v, ¶m) case parameter.TypeList: - param = list.Param{} + param = &list.Param{} err = mapstructure.Decode(v, ¶m) case parameter.TypeMultiList: - param = multilist.Param{} + param = &multilist.Param{} err = mapstructure.Decode(v, ¶m) default: return fmt.Errorf("unknown type %s", typestr) @@ -93,17 +92,17 @@ func (t *Template) Initialize() error { return standarderrs.Join(errs...) } group = param.GetGroup() - field = param.Render() - t.fields = append(t.fields, field) + param.Render() + t.fields = append(t.fields, param) if group == "" { group = UnknownGroup } if fields, ok := groups.Get(group); !ok { news := make([]huh.Field, 0) - news = append(news, field) + news = append(news, param) groups.Set(group, news) } else { - fields = append(fields, field) + fields = append(fields, param) groups.Set(group, fields) } }