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