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 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		if breakingChange && !hasBreakingChangeFooter(body) {
 67			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")
 68		}
 69
 70		var commitMsg strings.Builder
 71		commitMsg.WriteString(subject)
 72
 73		if body != "" {
 74			formattedBody, err := formatBody(body)
 75			if err != nil {
 76				return fmt.Errorf("failed to format body: %w", err)
 77			}
 78			commitMsg.WriteString("\n\n")
 79			commitMsg.WriteString(formattedBody)
 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 add {
 93			gitArgs = append(gitArgs, "-a")
 94		}
 95		if amend {
 96			gitArgs = append(gitArgs, "--amend")
 97		}
 98		gitArgs = append(gitArgs, "-F", "-")
 99		gitCmd := exec.Command("git", gitArgs...)
100		gitCmd.Stdout = os.Stdout
101		gitCmd.Stderr = os.Stderr
102
103		stdin, err := gitCmd.StdinPipe()
104		if err != nil {
105			return fmt.Errorf("failed to create stdin pipe: %w", err)
106		}
107
108		if err := gitCmd.Start(); err != nil {
109			return fmt.Errorf("failed to start git command: %w", err)
110		}
111
112		if _, err := stdin.Write([]byte(commitMsg.String())); err != nil {
113			return fmt.Errorf("failed to write to git stdin: %w", err)
114		}
115
116		if err := stdin.Close(); err != nil {
117			return fmt.Errorf("failed to close stdin: %w", err)
118		}
119
120		if err := gitCmd.Wait(); err != nil {
121			return fmt.Errorf("git commit failed: %w", err)
122		}
123
124		return nil
125	},
126}
127
128func init() {
129	rootCmd.Flags().StringVarP(&commitType, "type", "t", "", "commit type (required)")
130	rootCmd.Flags().StringVarP(&message, "message", "m", "", "commit message (required)")
131	rootCmd.Flags().StringArrayVarP(&trailers, "trailer", "T", []string{}, "trailer in 'Sentence-case-key: value' format (optional, repeatable)")
132	rootCmd.Flags().StringVarP(&body, "body", "b", "", "commit body (optional)")
133	rootCmd.Flags().StringVarP(&scope, "scope", "s", "", "commit scope (optional)")
134	rootCmd.Flags().BoolVarP(&breakingChange, "breaking", "B", false, "mark as breaking change (optional)")
135	rootCmd.Flags().BoolVarP(&add, "add", "a", false, "stage all modified files before committing (optional)")
136	rootCmd.Flags().BoolVar(&amend, "amend", false, "amend the previous commit (optional)")
137
138	if err := rootCmd.MarkFlagRequired("type"); err != nil {
139		panic(err)
140	}
141	if err := rootCmd.MarkFlagRequired("message"); err != nil {
142		panic(err)
143	}
144}
145
146func buildAndValidateSubject(commitType, scope, message string, breaking bool) (string, error) {
147	var subject strings.Builder
148
149	subject.WriteString(commitType)
150
151	if scope != "" {
152		subject.WriteString("(")
153		subject.WriteString(scope)
154		subject.WriteString(")")
155	}
156
157	if breaking {
158		subject.WriteString("!")
159	}
160
161	subject.WriteString(": ")
162	subject.WriteString(message)
163
164	result := subject.String()
165	length := len(result)
166
167	if length > 50 {
168		exceededBy := length - 50
169		truncated := result[:50] + "…"
170		return "", fmt.Errorf("subject exceeds 50 character limit by %d:\n%s", exceededBy, truncated)
171	}
172
173	return result, nil
174}
175
176func hasBreakingChangeFooter(body string) bool {
177	lines := strings.Split(body, "\n")
178	for _, line := range lines {
179		trimmed := strings.TrimSpace(line)
180		if strings.HasPrefix(trimmed, "BREAKING CHANGE:") || strings.HasPrefix(trimmed, "BREAKING CHANGES:") {
181			return true
182		}
183	}
184	return false
185}
186
187func main() {
188	ctx := context.Background()
189
190	var version string
191	if info, ok := debug.ReadBuildInfo(); ok {
192		version = info.Main.Version
193	}
194	if version == "" || version == "(devel)" {
195		version = "dev"
196	}
197
198	if err := fang.Execute(ctx, rootCmd,
199		fang.WithVersion(version),
200		fang.WithoutCompletions(),
201	); err != nil {
202		os.Exit(1)
203	}
204}