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 add bool
27 amend bool
28)
29
30var rootCmd = &cobra.Command{
31 Use: "formatted-commit",
32 Short: "Create conventionally formatted Git commits",
33 Long: `formatted-commit helps you create well-formatted Git commits that follow
34the Conventional Commits specification with proper subject length validation,
35body wrapping, and trailer formatting.`,
36 Example: `
37# With Assisted-by
38formatted-commit -t feat -m "do a thing" -T "Assisted-by: GLM 4.6 via Crush"
39
40# Breaking change with description
41formatted-commit -t feat -m "remove deprecated API" \
42 -B "The old /v1/users endpoint is removed. Use /v2/users instead."
43
44# Breaking change with multi-line description using heredoc
45formatted-commit -t feat -m "restructure config format" -B "$(cat <<'EOF'
46Configuration format changed from JSON to TOML.
47Migrate by running: ./migrate-config.sh
48EOF
49)" -b "Improves readability and supports comments"
50
51# Including scope for more precise changes
52formatted-commit -t refactor -s "web/git-bug" -m "fancy shmancy" \
53 -b "Had to do a weird thing because..."
54
55# Check for upgrades
56formatted-commit upgrade
57
58# Then apply
59formatted-commit upgrade -a
60`,
61 RunE: func(cmd *cobra.Command, args []string) error {
62 subject, err := buildAndValidateSubject(commitType, scope, message, breakingChange)
63 if err != nil {
64 return err
65 }
66
67 if breakingChange && !hasBreakingChangeFooter(body) {
68 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")
69 }
70
71 var commitMsg strings.Builder
72 commitMsg.WriteString(subject)
73
74 if body != "" {
75 formattedBody, err := formatBody(body)
76 if err != nil {
77 return fmt.Errorf("failed to format body: %w", err)
78 }
79 commitMsg.WriteString("\n\n")
80 commitMsg.WriteString(formattedBody)
81 }
82
83 if breakingChange != "" {
84 formattedBreaking, err := formatBody(breakingChange)
85 if err != nil {
86 return fmt.Errorf("failed to format breaking change: %w", err)
87 }
88 commitMsg.WriteString("\n\n")
89 commitMsg.WriteString("BREAKING CHANGE: ")
90 commitMsg.WriteString(formattedBreaking)
91 }
92
93 if len(trailers) > 0 {
94 trailersBlock, err := buildTrailersBlock(trailers)
95 if err != nil {
96 return fmt.Errorf("failed to build trailers: %w", err)
97 }
98 commitMsg.WriteString("\n\n")
99 commitMsg.WriteString(trailersBlock)
100 }
101
102 gitArgs := []string{"commit"}
103 if add {
104 gitArgs = append(gitArgs, "-a")
105 }
106 if amend {
107 gitArgs = append(gitArgs, "--amend")
108 }
109 gitArgs = append(gitArgs, "-F", "-")
110
111 gitCmd := exec.Command("git", gitArgs...)
112 gitCmd.Stdout = os.Stdout
113 gitCmd.Stderr = os.Stderr
114
115 stdin, err := gitCmd.StdinPipe()
116 if err != nil {
117 return fmt.Errorf("failed to create stdin pipe: %w", err)
118 }
119
120 if err := gitCmd.Start(); err != nil {
121 return fmt.Errorf("failed to start git command: %w", err)
122 }
123
124 if _, err := stdin.Write([]byte(commitMsg.String())); err != nil {
125 return fmt.Errorf("failed to write to git stdin: %w", err)
126 }
127
128 if err := stdin.Close(); err != nil {
129 return fmt.Errorf("failed to close stdin: %w", err)
130 }
131
132 if err := gitCmd.Wait(); err != nil {
133 return fmt.Errorf("git commit failed: %w", err)
134 }
135
136 return nil
137 },
138}
139
140func init() {
141 rootCmd.Flags().StringVarP(&commitType, "type", "t", "", "commit type (required)")
142 rootCmd.Flags().StringVarP(&message, "message", "m", "", "commit message (required)")
143 rootCmd.Flags().StringArrayVarP(&trailers, "trailer", "T", []string{}, "trailer in 'Sentence-case-key: value' format (optional, repeatable)")
144 rootCmd.Flags().StringVarP(&body, "body", "b", "", "commit body (optional)")
145 rootCmd.Flags().StringVarP(&scope, "scope", "s", "", "commit scope (optional)")
146 rootCmd.Flags().BoolVarP(&breakingChange, "breaking", "B", false, "mark as breaking change (optional)")
147 rootCmd.Flags().BoolVarP(&add, "add", "a", false, "stage all modified files before committing (optional)")
148 rootCmd.Flags().BoolVar(&amend, "amend", false, "amend the previous commit (optional)")
149
150 if err := rootCmd.MarkFlagRequired("type"); err != nil {
151 panic(err)
152 }
153 if err := rootCmd.MarkFlagRequired("message"); err != nil {
154 panic(err)
155 }
156}
157
158func buildAndValidateSubject(commitType, scope, message string, breaking string) (string, error) {
159 var subject strings.Builder
160
161 subject.WriteString(commitType)
162
163 if scope != "" {
164 subject.WriteString("(")
165 subject.WriteString(scope)
166 subject.WriteString(")")
167 }
168
169 if breaking != "" {
170 subject.WriteString("!")
171 }
172
173 subject.WriteString(": ")
174 subject.WriteString(message)
175
176 result := subject.String()
177 length := len(result)
178
179 if length > 50 {
180 exceededBy := length - 50
181 truncated := result[:50] + "…"
182 return "", fmt.Errorf("subject exceeds 50 character limit by %d:\n%s", exceededBy, truncated)
183 }
184
185 return result, nil
186}
187
188func hasBreakingChangeFooter(body string) bool {
189 lines := strings.Split(body, "\n")
190 for _, line := range lines {
191 trimmed := strings.TrimSpace(line)
192 if strings.HasPrefix(trimmed, "BREAKING CHANGE:") || strings.HasPrefix(trimmed, "BREAKING CHANGES:") {
193 return true
194 }
195 }
196 return false
197}
198
199func main() {
200 ctx := context.Background()
201
202 var version string
203 if info, ok := debug.ReadBuildInfo(); ok {
204 version = info.Main.Version
205 }
206 if version == "" || version == "(devel)" {
207 version = "dev"
208 }
209
210 if err := fang.Execute(ctx, rootCmd,
211 fang.WithVersion(version),
212 fang.WithoutCompletions(),
213 ); err != nil {
214 os.Exit(1)
215 }
216}