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 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 assisted-by trailer
37formatted-commit -t feat -m "do a thing" -T "Assisted-by: Veldt via Crush"
38
39# Breaking change with description
40formatted-commit -t feat -m "remove deprecated API" \
41 -B "The old /v1/users endpoint is removed. Use /v2/users instead."
42
43# Breaking change with multi-line description using heredoc
44formatted-commit -t feat -m "restructure config format" -B "$(cat <<'EOF'
45Configuration format changed from JSON to TOML.
46Migrate by running: ./migrate-config.sh
47EOF
48)" -b "Improves readability and supports comments"
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 RunE: func(cmd *cobra.Command, args []string) error {
55 subject, err := buildAndValidateSubject(commitType, scope, message, breakingChange)
56 if err != nil {
57 return err
58 }
59
60 var commitMsg strings.Builder
61 commitMsg.WriteString(subject)
62
63 if body != "" {
64 formattedBody, err := formatBody(body)
65 if err != nil {
66 return fmt.Errorf("failed to format body: %w", err)
67 }
68 commitMsg.WriteString("\n\n")
69 commitMsg.WriteString(formattedBody)
70 }
71
72 if breakingChange != "" {
73 formattedBreaking, err := formatBody(breakingChange)
74 if err != nil {
75 return fmt.Errorf("failed to format breaking change: %w", err)
76 }
77 commitMsg.WriteString("\n\n")
78 commitMsg.WriteString("BREAKING CHANGE: ")
79 commitMsg.WriteString(formattedBreaking)
80 }
81
82 if len(trailers) > 0 {
83 trailersBlock, err := buildTrailersBlock(trailers)
84 if err != nil {
85 return fmt.Errorf("failed to build trailers: %w", err)
86 }
87 commitMsg.WriteString("\n\n")
88 commitMsg.WriteString(trailersBlock)
89 }
90
91 gitArgs := []string{"commit"}
92 if amend {
93 gitArgs = append(gitArgs, "--amend")
94 }
95 gitArgs = append(gitArgs, "-F", "-")
96
97 gitCmd := exec.Command("git", gitArgs...)
98 gitCmd.Stdout = os.Stdout
99 gitCmd.Stderr = os.Stderr
100
101 stdin, err := gitCmd.StdinPipe()
102 if err != nil {
103 return fmt.Errorf("failed to create stdin pipe: %w", err)
104 }
105
106 if err := gitCmd.Start(); err != nil {
107 return fmt.Errorf("failed to start git command: %w", err)
108 }
109
110 if _, err := stdin.Write([]byte(commitMsg.String())); err != nil {
111 return fmt.Errorf("failed to write to git stdin: %w", err)
112 }
113
114 if err := stdin.Close(); err != nil {
115 return fmt.Errorf("failed to close stdin: %w", err)
116 }
117
118 if err := gitCmd.Wait(); err != nil {
119 return fmt.Errorf("git commit failed: %w", err)
120 }
121
122 return nil
123 },
124}
125
126func init() {
127 rootCmd.Flags().StringVarP(&commitType, "type", "t", "", "commit type (required)")
128 rootCmd.Flags().StringVarP(&message, "message", "m", "", "commit message (required)")
129 rootCmd.Flags().StringArrayVarP(&trailers, "trailer", "T", []string{}, "trailer in 'Sentence-case-key: value' format (optional, repeatable)")
130 rootCmd.Flags().StringVarP(&body, "body", "b", "", "commit body (optional)")
131 rootCmd.Flags().StringVarP(&scope, "scope", "s", "", "commit scope (optional)")
132 rootCmd.Flags().StringVarP(&breakingChange, "breaking", "B", "", "breaking change description (optional, adds BREAKING CHANGE footer)")
133 rootCmd.Flags().BoolVarP(&amend, "amend", "a", false, "amend the previous commit (optional)")
134
135 if err := rootCmd.MarkFlagRequired("type"); err != nil {
136 panic(err)
137 }
138 if err := rootCmd.MarkFlagRequired("message"); err != nil {
139 panic(err)
140 }
141}
142
143func buildAndValidateSubject(commitType, scope, message string, breaking string) (string, error) {
144 var subject strings.Builder
145
146 subject.WriteString(commitType)
147
148 if scope != "" {
149 subject.WriteString("(")
150 subject.WriteString(scope)
151 subject.WriteString(")")
152 }
153
154 if breaking != "" {
155 subject.WriteString("!")
156 }
157
158 subject.WriteString(": ")
159 subject.WriteString(message)
160
161 result := subject.String()
162 length := len(result)
163
164 if length > 50 {
165 exceededBy := length - 50
166 truncated := result[:50] + "…"
167 return "", fmt.Errorf("subject exceeds 50 character limit by %d:\n%s", exceededBy, truncated)
168 }
169
170 return result, nil
171}
172
173func main() {
174 ctx := context.Background()
175
176 var version string
177 if info, ok := debug.ReadBuildInfo(); ok {
178 version = info.Main.Version
179 }
180 if version == "" || version == "(devel)" {
181 version = "dev"
182 }
183
184 if err := fang.Execute(ctx, rootCmd,
185 fang.WithVersion(version),
186 fang.WithoutCompletions(),
187 ); err != nil {
188 os.Exit(1)
189 }
190}