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