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 "path/filepath"
12 "runtime/debug"
13 "strings"
14
15 "github.com/charmbracelet/fang"
16 "github.com/spf13/cobra"
17)
18
19type mode int
20
21const (
22 modeUnknown mode = iota
23 modeCommit
24 modeTag
25 modeRoot
26)
27
28var (
29 commitType string
30 message string
31 trailers []string
32 body string
33 scope string
34 breakingChange string
35 add bool
36 amend bool
37)
38
39func detectMode() mode {
40 base := filepath.Base(os.Args[0])
41 switch {
42 case strings.HasSuffix(base, "git-formatted-commit"):
43 return modeCommit
44 case strings.HasSuffix(base, "git-formatted-tag"):
45 return modeTag
46 case strings.HasSuffix(base, "git-format"):
47 return modeRoot
48 default:
49 return modeUnknown
50 }
51}
52
53var rootCmd = &cobra.Command{
54 Use: "git-format",
55 Short: "Format git commits and tags with proper conventions",
56 Long: `git-format helps you create well-formatted Git commits and tags with proper
57subject length validation, body wrapping, and trailer formatting.
58
59This tool is meant to be invoked via symlinks:
60 git formatted-commit - Create conventionally formatted commits
61 git formatted-tag - Create formatted annotated tags
62
63Run 'git-format install' to create the symlinks.`,
64 RunE: func(cmd *cobra.Command, args []string) error {
65 return cmd.Help()
66 },
67}
68
69var commitCmd = &cobra.Command{
70 Use: "git-formatted-commit",
71 Short: "Create conventionally formatted Git commits",
72 Long: `git-formatted-commit helps you create well-formatted Git commits that follow
73the Conventional Commits specification with proper subject length validation,
74body wrapping, and trailer formatting.`,
75 Example: `
76# With Assisted-by
77git formatted-commit -t feat -m "do a thing" -T "Assisted-by: GLM 4.6 via Crush"
78
79# Breaking change with description
80git formatted-commit -t feat -m "remove deprecated API" \
81 -B "The old /v1/users endpoint is removed. Use /v2/users instead."
82
83# Breaking change with multi-line description using heredoc
84git formatted-commit -t feat -m "restructure config format" -B "$(cat <<'EOF'
85Configuration format changed from JSON to TOML.
86Migrate by running: ./migrate-config.sh
87EOF
88)" -b "Improves readability and supports comments"
89
90# Including scope for more precise changes
91git formatted-commit -t refactor -s "web/git-bug" -m "fancy shmancy" \
92 -b "Had to do a weird thing because..."
93`,
94 RunE: func(cmd *cobra.Command, args []string) error {
95 subject, err := buildAndValidateSubject(commitType, scope, message, breakingChange)
96 if err != nil {
97 return err
98 }
99
100 var commitMsg strings.Builder
101 commitMsg.WriteString(subject)
102
103 if body != "" {
104 formattedBody, err := formatBody(body)
105 if err != nil {
106 return fmt.Errorf("failed to format body: %w", err)
107 }
108 commitMsg.WriteString("\n\n")
109 commitMsg.WriteString(formattedBody)
110 }
111
112 if breakingChange != "" {
113 formattedBreaking, err := formatBody("BREAKING CHANGE: " + breakingChange)
114 if err != nil {
115 return fmt.Errorf("failed to format breaking change: %w", err)
116 }
117 commitMsg.WriteString("\n\n")
118 commitMsg.WriteString(formattedBreaking)
119 }
120
121 if len(trailers) > 0 {
122 trailersBlock, err := buildTrailersBlock(trailers)
123 if err != nil {
124 return fmt.Errorf("failed to build trailers: %w", err)
125 }
126 commitMsg.WriteString("\n\n")
127 commitMsg.WriteString(trailersBlock)
128 }
129
130 gitArgs := []string{"commit"}
131 if add {
132 gitArgs = append(gitArgs, "-a")
133 }
134 if amend {
135 gitArgs = append(gitArgs, "--amend")
136 }
137 gitArgs = append(gitArgs, "-F", "-")
138
139 return runGitWithStdin(gitArgs, commitMsg.String())
140 },
141}
142
143func init() {
144 commitCmd.Flags().StringVarP(&commitType, "type", "t", "", "commit type (required)")
145 commitCmd.Flags().StringVarP(&message, "message", "m", "", "commit message (required)")
146 commitCmd.Flags().StringArrayVarP(&trailers, "trailer", "T", []string{}, "trailer in 'Sentence-case-key: value' format (optional, repeatable)")
147 commitCmd.Flags().StringVarP(&body, "body", "b", "", "commit body (optional)")
148 commitCmd.Flags().StringVarP(&scope, "scope", "s", "", "commit scope (optional)")
149 commitCmd.Flags().StringVarP(&breakingChange, "breaking", "B", "", "breaking change description (optional)")
150 commitCmd.Flags().BoolVarP(&add, "add", "a", false, "stage all modified files before committing (optional)")
151 commitCmd.Flags().BoolVar(&amend, "amend", false, "amend the previous commit (optional)")
152
153 if err := commitCmd.MarkFlagRequired("type"); err != nil {
154 panic(err)
155 }
156 if err := commitCmd.MarkFlagRequired("message"); err != nil {
157 panic(err)
158 }
159
160 rootCmd.AddCommand(installCmd)
161}
162
163func buildAndValidateSubject(commitType, scope, message string, breaking string) (string, error) {
164 var subject strings.Builder
165
166 subject.WriteString(commitType)
167
168 if scope != "" {
169 subject.WriteString("(")
170 subject.WriteString(scope)
171 subject.WriteString(")")
172 }
173
174 if breaking != "" {
175 subject.WriteString("!")
176 }
177
178 subject.WriteString(": ")
179 subject.WriteString(message)
180
181 result := subject.String()
182 if err := validateSubjectLength(result); err != nil {
183 return "", err
184 }
185 return result, nil
186}
187
188func main() {
189 ctx := context.Background()
190
191 var version string
192 if info, ok := debug.ReadBuildInfo(); ok {
193 version = info.Main.Version
194 }
195 if version == "" || version == "(devel)" {
196 version = "dev"
197 }
198
199 var cmd *cobra.Command
200 switch detectMode() {
201 case modeCommit:
202 cmd = commitCmd
203 case modeTag:
204 cmd = tagCmd
205 case modeRoot:
206 cmd = rootCmd
207 default:
208 fmt.Fprintln(os.Stderr, "Error: git-format must be invoked via symlink or as 'git-format'")
209 fmt.Fprintln(os.Stderr, "")
210 fmt.Fprintln(os.Stderr, "Run 'git-format install' to create:")
211 fmt.Fprintln(os.Stderr, " ~/.local/bin/git-formatted-commit")
212 fmt.Fprintln(os.Stderr, " ~/.local/bin/git-formatted-tag")
213 fmt.Fprintln(os.Stderr, "")
214 fmt.Fprintln(os.Stderr, "Then use: git formatted-commit ... or git formatted-tag ...")
215 os.Exit(1)
216 }
217
218 if err := fang.Execute(ctx, cmd,
219 fang.WithVersion(version),
220 fang.WithoutCompletions(),
221 ); err != nil {
222 os.Exit(1)
223 }
224}