main.go

  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}