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 string
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 var commitMsg strings.Builder
68 commitMsg.WriteString(subject)
69
70 if body != "" {
71 formattedBody, err := formatBody(body)
72 if err != nil {
73 return fmt.Errorf("failed to format body: %w", err)
74 }
75 commitMsg.WriteString("\n\n")
76 commitMsg.WriteString(formattedBody)
77 }
78
79 if breakingChange != "" {
80 formattedBreaking, err := formatBody("BREAKING CHANGE: " + breakingChange)
81 if err != nil {
82 return fmt.Errorf("failed to format breaking change: %w", err)
83 }
84 commitMsg.WriteString("\n\n")
85 commitMsg.WriteString(formattedBreaking)
86 }
87
88 if len(trailers) > 0 {
89 trailersBlock, err := buildTrailersBlock(trailers)
90 if err != nil {
91 return fmt.Errorf("failed to build trailers: %w", err)
92 }
93 commitMsg.WriteString("\n\n")
94 commitMsg.WriteString(trailersBlock)
95 }
96
97 gitArgs := []string{"commit"}
98 if add {
99 gitArgs = append(gitArgs, "-a")
100 }
101 if amend {
102 gitArgs = append(gitArgs, "--amend")
103 }
104 gitArgs = append(gitArgs, "-F", "-")
105
106 gitCmd := exec.Command("git", gitArgs...)
107 gitCmd.Stdout = os.Stdout
108 gitCmd.Stderr = os.Stderr
109
110 stdin, err := gitCmd.StdinPipe()
111 if err != nil {
112 return fmt.Errorf("failed to create stdin pipe: %w", err)
113 }
114
115 if err := gitCmd.Start(); err != nil {
116 return fmt.Errorf("failed to start git command: %w", err)
117 }
118
119 if _, err := stdin.Write([]byte(commitMsg.String())); err != nil {
120 return fmt.Errorf("failed to write to git stdin: %w", err)
121 }
122
123 if err := stdin.Close(); err != nil {
124 return fmt.Errorf("failed to close stdin: %w", err)
125 }
126
127 if err := gitCmd.Wait(); err != nil {
128 return fmt.Errorf("git commit failed: %w", err)
129 }
130
131 return nil
132 },
133}
134
135func init() {
136 rootCmd.Flags().StringVarP(&commitType, "type", "t", "", "commit type (required)")
137 rootCmd.Flags().StringVarP(&message, "message", "m", "", "commit message (required)")
138 rootCmd.Flags().StringArrayVarP(&trailers, "trailer", "T", []string{}, "trailer in 'Sentence-case-key: value' format (optional, repeatable)")
139 rootCmd.Flags().StringVarP(&body, "body", "b", "", "commit body (optional)")
140 rootCmd.Flags().StringVarP(&scope, "scope", "s", "", "commit scope (optional)")
141 rootCmd.Flags().StringVarP(&breakingChange, "breaking", "B", "", "breaking change description (optional)")
142 rootCmd.Flags().BoolVarP(&add, "add", "a", false, "stage all modified files before committing (optional)")
143 rootCmd.Flags().BoolVar(&amend, "amend", false, "amend the previous commit (optional)")
144
145 if err := rootCmd.MarkFlagRequired("type"); err != nil {
146 panic(err)
147 }
148 if err := rootCmd.MarkFlagRequired("message"); err != nil {
149 panic(err)
150 }
151}
152
153func buildAndValidateSubject(commitType, scope, message string, breaking string) (string, error) {
154 var subject strings.Builder
155
156 subject.WriteString(commitType)
157
158 if scope != "" {
159 subject.WriteString("(")
160 subject.WriteString(scope)
161 subject.WriteString(")")
162 }
163
164 if breaking != "" {
165 subject.WriteString("!")
166 }
167
168 subject.WriteString(": ")
169 subject.WriteString(message)
170
171 result := subject.String()
172 length := len(result)
173
174 if length > 50 {
175 exceededBy := length - 50
176 truncated := result[:50] + "…"
177 return "", fmt.Errorf("subject exceeds 50 character limit by %d:\n%s", exceededBy, truncated)
178 }
179
180 return result, nil
181}
182
183func main() {
184 ctx := context.Background()
185
186 var version string
187 if info, ok := debug.ReadBuildInfo(); ok {
188 version = info.Main.Version
189 }
190 if version == "" || version == "(devel)" {
191 version = "dev"
192 }
193
194 if err := fang.Execute(ctx, rootCmd,
195 fang.WithVersion(version),
196 fang.WithoutCompletions(),
197 ); err != nil {
198 os.Exit(1)
199 }
200}