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 "os/exec"
12 "runtime/debug"
13 "strings"
14
15 "github.com/charmbracelet/fang"
16 "github.com/spf13/cobra"
17)
18
19var (
20 commitType string
21 message string
22 trailers []string
23 body string
24 scope string
25 breakingChange bool
26)
27
28var rootCmd = &cobra.Command{
29 Use: "formatted-commit",
30 Short: "Create conventionally formatted Git commits",
31 Long: `formatted-commit helps you create well-formatted Git commits that follow
32the Conventional Commits specification with proper subject length validation,
33body wrapping, and trailer formatting.`,
34 Example: `
35# With co-author
36formatted-commit -t feat -m "do a thing" -T "Crush <crush@charm.land>"
37
38# Breaking change with longer body
39formatted-commit -t feat -m "do a thing that borks a thing" -B "$(cat <<'EOF'
40Multi-line
41- Body
42- Here
43
44This is what borked because of new shiny, this is how migrate
45EOF
46)"
47
48# Including scope for more precise changes
49formatted-commit -t refactor -s "web/git-bug" -m "fancy shmancy" \
50 -b "Had to do a weird thing because..."
51`,
52 RunE: func(cmd *cobra.Command, args []string) error {
53 subject, err := buildAndValidateSubject(commitType, scope, message, breakingChange)
54 if err != nil {
55 return err
56 }
57
58 if breakingChange && !hasBreakingChangeFooter(body) {
59 return fmt.Errorf("breaking change flag (-B) requires a BREAKING CHANGE: or CHANGES: footer at the end of the body. It instructs users how to resolve the breaking changes resulting from this commit")
60 }
61
62 var commitMsg strings.Builder
63 commitMsg.WriteString(subject)
64
65 if body != "" {
66 formattedBody, err := formatBody(body)
67 if err != nil {
68 return fmt.Errorf("failed to format body: %w", err)
69 }
70 commitMsg.WriteString("\n\n")
71 commitMsg.WriteString(formattedBody)
72 }
73
74 if len(trailers) > 0 {
75 trailersBlock, err := buildTrailersBlock(trailers)
76 if err != nil {
77 return fmt.Errorf("failed to build trailers: %w", err)
78 }
79 commitMsg.WriteString("\n\n")
80 commitMsg.WriteString(trailersBlock)
81 }
82
83 gitCmd := exec.Command("git", "commit", "-F", "-")
84 gitCmd.Stdout = os.Stdout
85 gitCmd.Stderr = os.Stderr
86
87 stdin, err := gitCmd.StdinPipe()
88 if err != nil {
89 return fmt.Errorf("failed to create stdin pipe: %w", err)
90 }
91
92 if err := gitCmd.Start(); err != nil {
93 return fmt.Errorf("failed to start git command: %w", err)
94 }
95
96 if _, err := stdin.Write([]byte(commitMsg.String())); err != nil {
97 return fmt.Errorf("failed to write to git stdin: %w", err)
98 }
99
100 if err := stdin.Close(); err != nil {
101 return fmt.Errorf("failed to close stdin: %w", err)
102 }
103
104 if err := gitCmd.Wait(); err != nil {
105 return fmt.Errorf("git commit failed: %w", err)
106 }
107
108 return nil
109 },
110}
111
112func init() {
113 rootCmd.Flags().StringVarP(&commitType, "type", "t", "", "commit type (required)")
114 rootCmd.Flags().StringVarP(&message, "message", "m", "", "commit message (required)")
115 rootCmd.Flags().StringArrayVarP(&trailers, "trailer", "T", []string{}, "trailer in 'Sentence-case-key: value' format (optional, repeatable)")
116 rootCmd.Flags().StringVarP(&body, "body", "b", "", "commit body (optional)")
117 rootCmd.Flags().StringVarP(&scope, "scope", "s", "", "commit scope (optional)")
118 rootCmd.Flags().BoolVarP(&breakingChange, "breaking", "B", false, "mark as breaking change (optional)")
119
120 if err := rootCmd.MarkFlagRequired("type"); err != nil {
121 panic(err)
122 }
123 if err := rootCmd.MarkFlagRequired("message"); err != nil {
124 panic(err)
125 }
126}
127
128func buildAndValidateSubject(commitType, scope, message string, breaking bool) (string, error) {
129 var subject strings.Builder
130
131 subject.WriteString(commitType)
132
133 if scope != "" {
134 subject.WriteString("(")
135 subject.WriteString(scope)
136 subject.WriteString(")")
137 }
138
139 if breaking {
140 subject.WriteString("!")
141 }
142
143 subject.WriteString(": ")
144 subject.WriteString(message)
145
146 result := subject.String()
147 length := len(result)
148
149 if length > 50 {
150 exceededBy := length - 50
151 truncated := result[:50] + "…"
152 return "", fmt.Errorf("subject exceeds 50 character limit by %d:\n%s", exceededBy, truncated)
153 }
154
155 return result, nil
156}
157
158func hasBreakingChangeFooter(body string) bool {
159 lines := strings.Split(body, "\n")
160 for _, line := range lines {
161 trimmed := strings.TrimSpace(line)
162 if strings.HasPrefix(trimmed, "BREAKING CHANGE:") || strings.HasPrefix(trimmed, "BREAKING CHANGES:") {
163 return true
164 }
165 }
166 return false
167}
168
169func main() {
170 ctx := context.Background()
171
172 var version string
173 if info, ok := debug.ReadBuildInfo(); ok {
174 version = info.Main.Version
175 }
176 if version == "" || version == "(devel)" {
177 version = "dev"
178 }
179
180 if err := fang.Execute(ctx, rootCmd,
181 fang.WithVersion(version),
182 fang.WithoutCompletions(),
183 ); err != nil {
184 os.Exit(1)
185 }
186}