1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5package main
6
7import (
8 "context"
9 "fmt"
10 "os"
11 "runtime/debug"
12 "strings"
13
14 "github.com/charmbracelet/fang"
15 "github.com/spf13/cobra"
16)
17
18var (
19 commitType string
20 message string
21 trailers []string
22 body string
23 scope string
24 breakingChange bool
25)
26
27var rootCmd = &cobra.Command{
28 Use: "formatted-commit",
29 Short: "Create conventionally formatted Git commits",
30 Long: `formatted-commit helps you create well-formatted Git commits that follow
31the Conventional Commits specification with proper subject length validation,
32body wrapping, and trailer formatting.`,
33 Example: `
34# With co-author
35formatted-commit -t feat -m "do a thing" -T "Crush <crush@charm.land>"
36
37# Breaking change with longer body
38formatted-commit -t feat -m "do a thing that borks a thing" -B "$(cat <<'EOF'
39Multi-line
40- Body
41- Here
42
43This is what borked because of new shiny, this is how migrate
44EOF
45)"
46
47# Including scope for more precise changes
48formatted-commit -t refactor -s "web/git-bug" -m "fancy shmancy" \
49 -b "Had to do a weird thing because..."
50`,
51 RunE: func(cmd *cobra.Command, args []string) error {
52 subject, err := buildAndValidateSubject(commitType, scope, message, breakingChange)
53 if err != nil {
54 return err
55 }
56
57 var commitMsg strings.Builder
58 commitMsg.WriteString(subject)
59
60 if body != "" {
61 formattedBody, err := formatBody(body)
62 if err != nil {
63 return fmt.Errorf("failed to format body: %w", err)
64 }
65 commitMsg.WriteString("\n\n")
66 commitMsg.WriteString(formattedBody)
67 }
68
69 if len(trailers) > 0 {
70 commitMsg.WriteString("\n\n")
71 for _, trailer := range trailers {
72 commitMsg.WriteString(trailer)
73 commitMsg.WriteString("\n")
74 }
75 }
76
77 fmt.Print(commitMsg.String())
78 return nil
79 },
80}
81
82func init() {
83 rootCmd.Flags().StringVarP(&commitType, "type", "t", "", "commit type (required)")
84 rootCmd.Flags().StringVarP(&message, "message", "m", "", "commit message (required)")
85 rootCmd.Flags().StringSliceVarP(&trailers, "trailer", "T", []string{}, "trailer in 'Sentence-case-key: value' format (optional, repeatable)")
86 rootCmd.Flags().StringVarP(&body, "body", "b", "", "commit body (optional)")
87 rootCmd.Flags().StringVarP(&scope, "scope", "s", "", "commit scope (optional)")
88 rootCmd.Flags().BoolVarP(&breakingChange, "breaking", "B", false, "mark as breaking change (optional)")
89
90 if err := rootCmd.MarkFlagRequired("type"); err != nil {
91 panic(err)
92 }
93 if err := rootCmd.MarkFlagRequired("message"); err != nil {
94 panic(err)
95 }
96}
97
98func buildAndValidateSubject(commitType, scope, message string, breaking bool) (string, error) {
99 var subject strings.Builder
100
101 subject.WriteString(commitType)
102
103 if scope != "" {
104 subject.WriteString("(")
105 subject.WriteString(scope)
106 subject.WriteString(")")
107 }
108
109 if breaking {
110 subject.WriteString("!")
111 }
112
113 subject.WriteString(": ")
114 subject.WriteString(message)
115
116 result := subject.String()
117 length := len(result)
118
119 if length > 50 {
120 exceededBy := length - 50
121 truncated := result[:50] + "…"
122 return "", fmt.Errorf("subject exceeds 50 character limit by %d:\n%s", exceededBy, truncated)
123 }
124
125 return result, nil
126}
127
128func main() {
129 ctx := context.Background()
130
131 var version string
132 if info, ok := debug.ReadBuildInfo(); ok {
133 version = info.Main.Version
134 }
135 if version == "" || version == "(devel)" {
136 version = "dev"
137 }
138
139 if err := fang.Execute(ctx, rootCmd,
140 fang.WithVersion(version),
141 fang.WithoutCompletions(),
142 ); err != nil {
143 os.Exit(1)
144 }
145}