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	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 longer body
 41formatted-commit -t feat -m "do a thing that borks a thing" -B -b "$(cat <<'EOF'
 42Multi-line
 43- Body
 44- Here
 45
 46This is what borked because of new shiny, this is how migrate
 47EOF
 48)"
 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# Check for upgrades
 55formatted-commit upgrade
 56
 57# Then apply
 58formatted-commit upgrade -a
 59`,
 60	RunE: func(cmd *cobra.Command, args []string) error {
 61		subject, err := buildAndValidateSubject(commitType, scope, message, breakingChange)
 62		if err != nil {
 63			return err
 64		}
 65
 66		var commitMsg strings.Builder
 67		commitMsg.WriteString(subject)
 68
 69		if body != "" {
 70			formattedBody, err := formatBody(body)
 71			if err != nil {
 72				return fmt.Errorf("failed to format body: %w", err)
 73			}
 74			commitMsg.WriteString("\n\n")
 75			commitMsg.WriteString(formattedBody)
 76		}
 77
 78		if len(trailers) > 0 {
 79			trailersBlock, err := buildTrailersBlock(trailers)
 80			if err != nil {
 81				return fmt.Errorf("failed to build trailers: %w", err)
 82			}
 83			commitMsg.WriteString("\n\n")
 84			commitMsg.WriteString(trailersBlock)
 85		}
 86
 87		gitArgs := []string{"commit"}
 88		if add {
 89			gitArgs = append(gitArgs, "-a")
 90		}
 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(&add, "add", "a", false, "stage all modified files before committing (optional)")
132	rootCmd.Flags().BoolVar(&amend, "amend", false, "amend the previous commit (optional)")
133
134	if err := rootCmd.MarkFlagRequired("type"); err != nil {
135		panic(err)
136	}
137	if err := rootCmd.MarkFlagRequired("message"); err != nil {
138		panic(err)
139	}
140}
141
142func buildAndValidateSubject(commitType, scope, message string, breaking bool) (string, error) {
143	var subject strings.Builder
144
145	subject.WriteString(commitType)
146
147	if scope != "" {
148		subject.WriteString("(")
149		subject.WriteString(scope)
150		subject.WriteString(")")
151	}
152
153	if breaking {
154		subject.WriteString("!")
155	}
156
157	subject.WriteString(": ")
158	subject.WriteString(message)
159
160	result := subject.String()
161	length := len(result)
162
163	if length > 50 {
164		exceededBy := length - 50
165		truncated := result[:50] + "…"
166		return "", fmt.Errorf("subject exceeds 50 character limit by %d:\n%s", exceededBy, truncated)
167	}
168
169	return result, nil
170}
171
172func main() {
173	ctx := context.Background()
174
175	var version string
176	if info, ok := debug.ReadBuildInfo(); ok {
177		version = info.Main.Version
178	}
179	if version == "" || version == "(devel)" {
180		version = "dev"
181	}
182
183	if err := fang.Execute(ctx, rootCmd,
184		fang.WithVersion(version),
185		fang.WithoutCompletions(),
186	); err != nil {
187		os.Exit(1)
188	}
189}