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 trailersBlock, err := buildTrailersBlock(trailers)
71 if err != nil {
72 return fmt.Errorf("failed to build trailers: %w", err)
73 }
74 commitMsg.WriteString("\n\n")
75 commitMsg.WriteString(trailersBlock)
76 }
77
78 fmt.Print(commitMsg.String())
79 return nil
80 },
81}
82
83func init() {
84 rootCmd.Flags().StringVarP(&commitType, "type", "t", "", "commit type (required)")
85 rootCmd.Flags().StringVarP(&message, "message", "m", "", "commit message (required)")
86 rootCmd.Flags().StringArrayVarP(&trailers, "trailer", "T", []string{}, "trailer in 'Sentence-case-key: value' format (optional, repeatable)")
87 rootCmd.Flags().StringVarP(&body, "body", "b", "", "commit body (optional)")
88 rootCmd.Flags().StringVarP(&scope, "scope", "s", "", "commit scope (optional)")
89 rootCmd.Flags().BoolVarP(&breakingChange, "breaking", "B", false, "mark as breaking change (optional)")
90
91 if err := rootCmd.MarkFlagRequired("type"); err != nil {
92 panic(err)
93 }
94 if err := rootCmd.MarkFlagRequired("message"); err != nil {
95 panic(err)
96 }
97}
98
99func buildAndValidateSubject(commitType, scope, message string, breaking bool) (string, error) {
100 var subject strings.Builder
101
102 subject.WriteString(commitType)
103
104 if scope != "" {
105 subject.WriteString("(")
106 subject.WriteString(scope)
107 subject.WriteString(")")
108 }
109
110 if breaking {
111 subject.WriteString("!")
112 }
113
114 subject.WriteString(": ")
115 subject.WriteString(message)
116
117 result := subject.String()
118 length := len(result)
119
120 if length > 50 {
121 exceededBy := length - 50
122 truncated := result[:50] + "…"
123 return "", fmt.Errorf("subject exceeds 50 character limit by %d:\n%s", exceededBy, truncated)
124 }
125
126 return result, nil
127}
128
129func main() {
130 ctx := context.Background()
131
132 var version string
133 if info, ok := debug.ReadBuildInfo(); ok {
134 version = info.Main.Version
135 }
136 if version == "" || version == "(devel)" {
137 version = "dev"
138 }
139
140 if err := fang.Execute(ctx, rootCmd,
141 fang.WithVersion(version),
142 fang.WithoutCompletions(),
143 ); err != nil {
144 os.Exit(1)
145 }
146}