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