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