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 co-author
 37formatted-commit -t feat -m "do a thing" -T "Crush <crush@charm.land>"
 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	RunE: func(cmd *cobra.Command, args []string) error {
 54		subject, err := buildAndValidateSubject(commitType, scope, message, breakingChange)
 55		if err != nil {
 56			return err
 57		}
 58
 59		var commitMsg strings.Builder
 60		commitMsg.WriteString(subject)
 61
 62		if body != "" {
 63			formattedBody, err := formatBody(body)
 64			if err != nil {
 65				return fmt.Errorf("failed to format body: %w", err)
 66			}
 67			commitMsg.WriteString("\n\n")
 68			commitMsg.WriteString(formattedBody)
 69		}
 70
 71		if len(trailers) > 0 {
 72			trailersBlock, err := buildTrailersBlock(trailers)
 73			if err != nil {
 74				return fmt.Errorf("failed to build trailers: %w", err)
 75			}
 76			commitMsg.WriteString("\n\n")
 77			commitMsg.WriteString(trailersBlock)
 78		}
 79
 80		gitArgs := []string{"commit"}
 81		if amend {
 82			gitArgs = append(gitArgs, "--amend")
 83		}
 84		gitArgs = append(gitArgs, "-F", "-")
 85		gitCmd := exec.Command("git", gitArgs...)
 86		gitCmd.Stdout = os.Stdout
 87		gitCmd.Stderr = os.Stderr
 88
 89		stdin, err := gitCmd.StdinPipe()
 90		if err != nil {
 91			return fmt.Errorf("failed to create stdin pipe: %w", err)
 92		}
 93
 94		if err := gitCmd.Start(); err != nil {
 95			return fmt.Errorf("failed to start git command: %w", err)
 96		}
 97
 98		if _, err := stdin.Write([]byte(commitMsg.String())); err != nil {
 99			return fmt.Errorf("failed to write to git stdin: %w", err)
100		}
101
102		if err := stdin.Close(); err != nil {
103			return fmt.Errorf("failed to close stdin: %w", err)
104		}
105
106		if err := gitCmd.Wait(); err != nil {
107			return fmt.Errorf("git commit failed: %w", err)
108		}
109
110		return nil
111	},
112}
113
114func init() {
115	rootCmd.Flags().StringVarP(&commitType, "type", "t", "", "commit type (required)")
116	rootCmd.Flags().StringVarP(&message, "message", "m", "", "commit message (required)")
117	rootCmd.Flags().StringArrayVarP(&trailers, "trailer", "T", []string{}, "trailer in 'Sentence-case-key: value' format (optional, repeatable)")
118	rootCmd.Flags().StringVarP(&body, "body", "b", "", "commit body (optional)")
119	rootCmd.Flags().StringVarP(&scope, "scope", "s", "", "commit scope (optional)")
120	rootCmd.Flags().BoolVarP(&breakingChange, "breaking", "B", false, "mark as breaking change (optional)")
121	rootCmd.Flags().BoolVarP(&amend, "amend", "a", false, "amend the previous commit (optional)")
122
123	if err := rootCmd.MarkFlagRequired("type"); err != nil {
124		panic(err)
125	}
126	if err := rootCmd.MarkFlagRequired("message"); err != nil {
127		panic(err)
128	}
129}
130
131func buildAndValidateSubject(commitType, scope, message string, breaking bool) (string, error) {
132	var subject strings.Builder
133
134	subject.WriteString(commitType)
135
136	if scope != "" {
137		subject.WriteString("(")
138		subject.WriteString(scope)
139		subject.WriteString(")")
140	}
141
142	if breaking {
143		subject.WriteString("!")
144	}
145
146	subject.WriteString(": ")
147	subject.WriteString(message)
148
149	result := subject.String()
150	length := len(result)
151
152	if length > 50 {
153		exceededBy := length - 50
154		truncated := result[:50] + "…"
155		return "", fmt.Errorf("subject exceeds 50 character limit by %d:\n%s", exceededBy, truncated)
156	}
157
158	return result, nil
159}
160
161func main() {
162	ctx := context.Background()
163
164	var version string
165	if info, ok := debug.ReadBuildInfo(); ok {
166		version = info.Main.Version
167	}
168	if version == "" || version == "(devel)" {
169		version = "dev"
170	}
171
172	if err := fang.Execute(ctx, rootCmd,
173		fang.WithVersion(version),
174		fang.WithoutCompletions(),
175	); err != nil {
176		os.Exit(1)
177	}
178}