From 199b9600f10d58f19e240c606264f42c18025ac7 Mon Sep 17 00:00:00 2001 From: Ruslan Kokoev Date: Thu, 28 May 2026 22:25:13 +0300 Subject: [PATCH 1/2] feat!: Move run logic in separate subcommand --- cmd/commands/run.go | 156 ++++++++++++++++++++++++++++++++++++++++++++ cmd/root.go | 154 ++----------------------------------------- 2 files changed, 162 insertions(+), 148 deletions(-) create mode 100644 cmd/commands/run.go diff --git a/cmd/commands/run.go b/cmd/commands/run.go new file mode 100644 index 0000000..b5a29a3 --- /dev/null +++ b/cmd/commands/run.go @@ -0,0 +1,156 @@ +package commands + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "os" + + "github.com/fatih/color" + "github.com/thumbrise/commitlint-scope/pkg/validator" + "github.com/urfave/cli/v3" +) + +var ( + flagRunFrom string + flagRunTo string + flagRunVerbose bool + flagRunNoColor bool + flagRunJSON bool +) + +var RunCMD = &cli.Command{ + Name: "run", + Usage: "Lint commit scopes against changed files", + UsageText: "commitlint-scope run --from --to ", + Description: `Validate that scopes declared in commit messages correspond to actually changed files + +The command inspects a range of commits (from exclusive, to inclusive) and reports any scope that does not match the files modified in that commit. + +Examples: + commitlint-scope run --from main --to feature-branch + commitlint-scope run --from HEAD~5 --to HEAD +`, + Suggest: true, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "from", + Aliases: []string{"f"}, + Usage: "Start of the commit range (exclusive)", + Required: true, + Destination: &flagRunFrom, + }, + &cli.StringFlag{ + Name: "to", + Aliases: []string{"t"}, + Usage: "End of the commit range (inclusive)", + Required: true, + Destination: &flagRunTo, + }, + &cli.BoolFlag{ + Name: "verbose", + Aliases: []string{"v"}, + Usage: "Verbose output", + Required: false, + Destination: &flagRunVerbose, + }, + &cli.BoolFlag{ + Name: "no-color", + Usage: "Disable color output", + Required: false, + Destination: &flagRunNoColor, + }, + &cli.BoolFlag{ + Name: "json", + Usage: "Show output in JSON format", + Required: false, + Destination: &flagRunJSON, + }, + }, + + Action: func(ctx context.Context, cmd *cli.Command) error { + color.NoColor = flagRunNoColor + + configureLogger(os.Stderr, flagRunVerbose) + + cfg, err := validator.LoadConfig() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + vld, err := validator.NewValidator(cfg, validator.Options{ + Logger: slog.Default(), + SHALength: 7, + Git: nil, + OutsiderFinder: nil, + ScopeParser: nil, + }) + if err != nil { + return fmt.Errorf("creating validator: %w", err) + } + + violations, err := vld.Validate(ctx, flagRunFrom, flagRunTo) + if err != nil { + return fmt.Errorf("validation failed: %w", err) + } + + if len(violations) == 0 { + return nil + } + + msg := fmt.Sprintf("%d violation(s) found:", len(violations)) + _, _ = color.New(color.FgRed, color.Bold).Fprintln(cmd.ErrWriter, msg) + + if flagRunJSON { + err := jsonOutput(cmd.Writer, violations) + if err != nil { + return err + } + } else { + textOutput(cmd.Writer, violations) + } + + return nil + }, +} + +func textOutput(w io.Writer, violations []validator.Violation) { + shaColor := color.New(color.FgYellow, color.Bold) + for _, v := range violations { + _, _ = fmt.Fprintf(w, "%s %s\n", shaColor.Sprintf("%s", v.SHA), v.Header) + for _, o := range v.Outsiders { + _, _ = fmt.Fprintf(w, " - %s\n", o.File) + } + } +} + +func jsonOutput(writer io.Writer, violations []validator.Violation) error { + encoder := json.NewEncoder(writer) + encoder.SetIndent("", " ") + + if err := encoder.Encode(violations); err != nil { + return fmt.Errorf("failed to output violations: %w", err) + } + + return nil +} + +func configureLogger(writer io.Writer, verbose bool) { + opts := &slog.HandlerOptions{ + AddSource: false, + Level: slog.LevelInfo, + } + + if verbose { + opts.Level = slog.LevelDebug + opts.AddSource = true + } + + handler := slog.NewTextHandler(writer, opts) + + logger := slog.New(handler) + + slog.SetDefault(logger) +} diff --git a/cmd/root.go b/cmd/root.go index 9b76474..b5c00a8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,158 +1,16 @@ package cmd import ( - "context" - "encoding/json" - "fmt" - "io" - "log/slog" - "os" - - "github.com/fatih/color" - "github.com/thumbrise/commitlint-scope/pkg/validator" + "github.com/thumbrise/commitlint-scope/cmd/commands" "github.com/urfave/cli/v3" ) -var ( - flagFrom string - flagTo string - flagVerbose bool - flagNoColor bool - flagJSON bool -) - // Root is the entry point command for commitlint-scope. var Root = &cli.Command{ - Name: "commitlint-scope", - Usage: "Lint commit scopes against changed files.", - UsageText: "commitlint-scope --from --to ", - Description: `Validate that scopes declared in commit messages correspond to actually changed files. - -The command inspects a range of commits (from exclusive, to inclusive) and reports any scope that does not match the files modified in that commit. - -Examples: - commitlint-scope --from main --to feature-branch - commitlint-scope --from HEAD~5 --to HEAD -`, - - Suggest: true, - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "from", - Aliases: []string{"f"}, - Usage: "Start of the commit range (exclusive)", - Required: true, - Destination: &flagFrom, - }, - &cli.StringFlag{ - Name: "to", - Aliases: []string{"t"}, - Usage: "End of the commit range (inclusive)", - Required: true, - Destination: &flagTo, - }, - &cli.BoolFlag{ - Name: "verbose", - Aliases: []string{"v"}, - Usage: "Verbose output", - Required: false, - Destination: &flagVerbose, - }, - &cli.BoolFlag{ - Name: "no-color", - Usage: "Disable color output", - Required: false, - Destination: &flagNoColor, - }, - &cli.BoolFlag{ - Name: "json", - Usage: "Show output in JSON format", - Required: false, - Destination: &flagJSON, - }, - }, - - Action: func(ctx context.Context, cmd *cli.Command) error { - color.NoColor = flagNoColor - - configureLogger(os.Stderr, flagVerbose) - - cfg, err := validator.LoadConfig() - if err != nil { - return fmt.Errorf("loading config: %w", err) - } - - vld, err := validator.NewValidator(cfg, validator.Options{ - Logger: slog.Default(), - SHALength: 7, - Git: nil, - OutsiderFinder: nil, - ScopeParser: nil, - }) - if err != nil { - return fmt.Errorf("creating validator: %w", err) - } - - violations, err := vld.Validate(ctx, flagFrom, flagTo) - if err != nil { - return fmt.Errorf("validation failed: %w", err) - } - - if len(violations) == 0 { - return nil - } - - msg := fmt.Sprintf("%d violation(s) found:", len(violations)) - _, _ = color.New(color.FgRed, color.Bold).Fprintln(cmd.ErrWriter, msg) - - if flagJSON { - err := jsonOutput(cmd.Writer, violations) - if err != nil { - return err - } - } else { - textOutput(cmd.Writer, violations) - } - - return nil + Name: "commitlint-scope", + Description: `commitlint-scope - a linter that checks if declared commit scopes match the changed files`, + Commands: []*cli.Command{ + commands.RunCMD, }, -} - -func textOutput(w io.Writer, violations []validator.Violation) { - shaColor := color.New(color.FgYellow, color.Bold) - for _, v := range violations { - _, _ = fmt.Fprintf(w, "%s %s\n", shaColor.Sprintf("%s", v.SHA), v.Header) - for _, o := range v.Outsiders { - _, _ = fmt.Fprintf(w, " - %s\n", o.File) - } - } -} - -func jsonOutput(writer io.Writer, violations []validator.Violation) error { - encoder := json.NewEncoder(writer) - encoder.SetIndent("", " ") - - if err := encoder.Encode(violations); err != nil { - return fmt.Errorf("failed to output violations: %w", err) - } - - return nil -} - -func configureLogger(writer io.Writer, verbose bool) { - opts := &slog.HandlerOptions{ - AddSource: false, - Level: slog.LevelInfo, - } - - if verbose { - opts.Level = slog.LevelDebug - opts.AddSource = true - } - - handler := slog.NewTextHandler(writer, opts) - - logger := slog.New(handler) - - slog.SetDefault(logger) + Suggest: true, } From 3323bc4d943448dc37e7e3832b79d0d89b01aea8 Mon Sep 17 00:00:00 2001 From: Ruslan Kokoev Date: Thu, 28 May 2026 23:44:06 +0300 Subject: [PATCH 2/2] fix: Return proper exit code on violations found --- cmd/commands/run.go | 6 ++---- cmd/errs/violations_found_error.go | 21 +++++++++++++++++++++ cmd/root.go | 17 +++++++++++++++++ main.go | 2 +- 4 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 cmd/errs/violations_found_error.go diff --git a/cmd/commands/run.go b/cmd/commands/run.go index b5a29a3..65017b9 100644 --- a/cmd/commands/run.go +++ b/cmd/commands/run.go @@ -9,6 +9,7 @@ import ( "os" "github.com/fatih/color" + "github.com/thumbrise/commitlint-scope/cmd/errs" "github.com/thumbrise/commitlint-scope/pkg/validator" "github.com/urfave/cli/v3" ) @@ -100,9 +101,6 @@ Examples: return nil } - msg := fmt.Sprintf("%d violation(s) found:", len(violations)) - _, _ = color.New(color.FgRed, color.Bold).Fprintln(cmd.ErrWriter, msg) - if flagRunJSON { err := jsonOutput(cmd.Writer, violations) if err != nil { @@ -112,7 +110,7 @@ Examples: textOutput(cmd.Writer, violations) } - return nil + return errs.NewViolationsFound(len(violations)) }, } diff --git a/cmd/errs/violations_found_error.go b/cmd/errs/violations_found_error.go new file mode 100644 index 0000000..36e0753 --- /dev/null +++ b/cmd/errs/violations_found_error.go @@ -0,0 +1,21 @@ +package errs + +import ( + "fmt" +) + +type ViolationsFoundError struct { + num int +} + +func NewViolationsFound(num int) *ViolationsFoundError { + return &ViolationsFoundError{num: num} +} + +func (v ViolationsFoundError) Error() string { + return fmt.Sprintf("%d violations found", v.num) +} + +func (v ViolationsFoundError) ExitCode() int { + return 2 +} diff --git a/cmd/root.go b/cmd/root.go index b5c00a8..e5702c8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,6 +1,11 @@ package cmd import ( + "context" + "errors" + "os" + + "github.com/fatih/color" "github.com/thumbrise/commitlint-scope/cmd/commands" "github.com/urfave/cli/v3" ) @@ -13,4 +18,16 @@ var Root = &cli.Command{ commands.RunCMD, }, Suggest: true, + ExitErrHandler: func(ctx context.Context, command *cli.Command, err error) { + code := 1 + + if coder, ok := errors.AsType[cli.ExitCoder](err); ok { + code = coder.ExitCode() + } + + _, _ = color.New(color.FgRed, color.Bold).Fprintf(os.Stderr, "\n%s\n", err) + _, _ = color.New(color.FgRed, color.Bold).Fprintf(os.Stderr, "\nexit code %d\n", code) + + os.Exit(code) + }, } diff --git a/main.go b/main.go index 4290524..25a98ff 100644 --- a/main.go +++ b/main.go @@ -12,7 +12,7 @@ func main() { ctx := context.Background() if err := cmd.Root.Run(ctx, os.Args); err != nil { - _, _ = fmt.Fprintf(os.Stderr, "error: %[1]v\n", err) + _, _ = fmt.Fprintf(os.Stderr, "fatal error: %[1]v\n", err) os.Exit(1) }