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 string
 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 trailer
 37formatted-commit -t feat -m "do a thing" -T "Assisted-by: Veldt via Crush"
 38
 39# Breaking change with description
 40formatted-commit -t feat -m "remove deprecated API" \
 41  -B "The old /v1/users endpoint is removed. Use /v2/users instead."
 42
 43# Breaking change with multi-line description using heredoc
 44formatted-commit -t feat -m "restructure config format" -B "$(cat <<'EOF'
 45Configuration format changed from JSON to TOML.
 46Migrate by running: ./migrate-config.sh
 47EOF
 48)" -b "Improves readability and supports comments"
 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	RunE: func(cmd *cobra.Command, args []string) error {
 55		subject, err := buildAndValidateSubject(commitType, scope, message, breakingChange)
 56		if err != nil {
 57			return err
 58		}
 59
 60		var commitMsg strings.Builder
 61		commitMsg.WriteString(subject)
 62
 63		if body != "" {
 64			formattedBody, err := formatBody(body)
 65			if err != nil {
 66				return fmt.Errorf("failed to format body: %w", err)
 67			}
 68			commitMsg.WriteString("\n\n")
 69			commitMsg.WriteString(formattedBody)
 70		}
 71
 72		if breakingChange != "" {
 73			formattedBreaking, err := formatBody(breakingChange)
 74			if err != nil {
 75				return fmt.Errorf("failed to format breaking change: %w", err)
 76			}
 77			commitMsg.WriteString("\n\n")
 78			commitMsg.WriteString("BREAKING CHANGE: ")
 79			commitMsg.WriteString(formattedBreaking)
 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 amend {
 93			gitArgs = append(gitArgs, "--amend")
 94		}
 95		gitArgs = append(gitArgs, "-F", "-")
 96
 97		gitCmd := exec.Command("git", gitArgs...)
 98		gitCmd.Stdout = os.Stdout
 99		gitCmd.Stderr = os.Stderr
100
101		stdin, err := gitCmd.StdinPipe()
102		if err != nil {
103			return fmt.Errorf("failed to create stdin pipe: %w", err)
104		}
105
106		if err := gitCmd.Start(); err != nil {
107			return fmt.Errorf("failed to start git command: %w", err)
108		}
109
110		if _, err := stdin.Write([]byte(commitMsg.String())); err != nil {
111			return fmt.Errorf("failed to write to git stdin: %w", err)
112		}
113
114		if err := stdin.Close(); err != nil {
115			return fmt.Errorf("failed to close stdin: %w", err)
116		}
117
118		if err := gitCmd.Wait(); err != nil {
119			return fmt.Errorf("git commit failed: %w", err)
120		}
121
122		return nil
123	},
124}
125
126func init() {
127	rootCmd.Flags().StringVarP(&commitType, "type", "t", "", "commit type (required)")
128	rootCmd.Flags().StringVarP(&message, "message", "m", "", "commit message (required)")
129	rootCmd.Flags().StringArrayVarP(&trailers, "trailer", "T", []string{}, "trailer in 'Sentence-case-key: value' format (optional, repeatable)")
130	rootCmd.Flags().StringVarP(&body, "body", "b", "", "commit body (optional)")
131	rootCmd.Flags().StringVarP(&scope, "scope", "s", "", "commit scope (optional)")
132	rootCmd.Flags().StringVarP(&breakingChange, "breaking", "B", "", "breaking change description (optional, adds BREAKING CHANGE footer)")
133	rootCmd.Flags().BoolVarP(&amend, "amend", "a", false, "amend the previous commit (optional)")
134
135	if err := rootCmd.MarkFlagRequired("type"); err != nil {
136		panic(err)
137	}
138	if err := rootCmd.MarkFlagRequired("message"); err != nil {
139		panic(err)
140	}
141}
142
143func buildAndValidateSubject(commitType, scope, message string, breaking string) (string, error) {
144	var subject strings.Builder
145
146	subject.WriteString(commitType)
147
148	if scope != "" {
149		subject.WriteString("(")
150		subject.WriteString(scope)
151		subject.WriteString(")")
152	}
153
154	if breaking != "" {
155		subject.WriteString("!")
156	}
157
158	subject.WriteString(": ")
159	subject.WriteString(message)
160
161	result := subject.String()
162	length := len(result)
163
164	if length > 50 {
165		exceededBy := length - 50
166		truncated := result[:50] + "…"
167		return "", fmt.Errorf("subject exceeds 50 character limit by %d:\n%s", exceededBy, truncated)
168	}
169
170	return result, nil
171}
172
173func main() {
174	ctx := context.Background()
175
176	var version string
177	if info, ok := debug.ReadBuildInfo(); ok {
178		version = info.Main.Version
179	}
180	if version == "" || version == "(devel)" {
181		version = "dev"
182	}
183
184	if err := fang.Execute(ctx, rootCmd,
185		fang.WithVersion(version),
186		fang.WithoutCompletions(),
187	); err != nil {
188		os.Exit(1)
189	}
190}