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