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