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	"path/filepath"
 12	"runtime/debug"
 13	"strings"
 14
 15	"github.com/charmbracelet/fang"
 16	"github.com/spf13/cobra"
 17)
 18
 19type mode int
 20
 21const (
 22	modeUnknown mode = iota
 23	modeCommit
 24	modeTag
 25	modeRoot
 26)
 27
 28var (
 29	commitType     string
 30	message        string
 31	trailers       []string
 32	body           string
 33	scope          string
 34	breakingChange string
 35	add            bool
 36	amend          bool
 37)
 38
 39func detectMode() mode {
 40	base := filepath.Base(os.Args[0])
 41	switch {
 42	case strings.HasSuffix(base, "git-formatted-commit"):
 43		return modeCommit
 44	case strings.HasSuffix(base, "git-formatted-tag"):
 45		return modeTag
 46	case strings.HasSuffix(base, "git-format"):
 47		return modeRoot
 48	default:
 49		return modeUnknown
 50	}
 51}
 52
 53var rootCmd = &cobra.Command{
 54	Use:   "git-format",
 55	Short: "Format git commits and tags with proper conventions",
 56	Long: `git-format helps you create well-formatted Git commits and tags with proper
 57subject length validation, body wrapping, and trailer formatting.
 58
 59This tool is meant to be invoked via symlinks:
 60  git formatted-commit  - Create conventionally formatted commits
 61  git formatted-tag     - Create formatted annotated tags
 62
 63Run 'git-format install' to create the symlinks.`,
 64	RunE: func(cmd *cobra.Command, args []string) error {
 65		return cmd.Help()
 66	},
 67}
 68
 69var commitCmd = &cobra.Command{
 70	Use:   "git-formatted-commit",
 71	Short: "Create conventionally formatted Git commits",
 72	Long: `git-formatted-commit helps you create well-formatted Git commits that follow
 73the Conventional Commits specification with proper subject length validation,
 74body wrapping, and trailer formatting.`,
 75	Example: `
 76# With Assisted-by
 77git formatted-commit -t feat -m "do a thing" -T "Assisted-by: GLM 4.6 via Crush"
 78
 79# Breaking change with description
 80git formatted-commit -t feat -m "remove deprecated API" \
 81  -B "The old /v1/users endpoint is removed. Use /v2/users instead."
 82
 83# Breaking change with multi-line description using heredoc
 84git formatted-commit -t feat -m "restructure config format" -B "$(cat <<'EOF'
 85Configuration format changed from JSON to TOML.
 86Migrate by running: ./migrate-config.sh
 87EOF
 88)" -b "Improves readability and supports comments"
 89
 90# Including scope for more precise changes
 91git formatted-commit -t refactor -s "web/git-bug" -m "fancy shmancy" \
 92  -b "Had to do a weird thing because..."
 93`,
 94	RunE: func(cmd *cobra.Command, args []string) error {
 95		subject, err := buildAndValidateSubject(commitType, scope, message, breakingChange)
 96		if err != nil {
 97			return err
 98		}
 99
100		var commitMsg strings.Builder
101		commitMsg.WriteString(subject)
102
103		if body != "" {
104			formattedBody, err := formatBody(body)
105			if err != nil {
106				return fmt.Errorf("failed to format body: %w", err)
107			}
108			commitMsg.WriteString("\n\n")
109			commitMsg.WriteString(formattedBody)
110		}
111
112		if breakingChange != "" {
113			formattedBreaking, err := formatBody("BREAKING CHANGE: " + breakingChange)
114			if err != nil {
115				return fmt.Errorf("failed to format breaking change: %w", err)
116			}
117			commitMsg.WriteString("\n\n")
118			commitMsg.WriteString(formattedBreaking)
119		}
120
121		if len(trailers) > 0 {
122			trailersBlock, err := buildTrailersBlock(trailers)
123			if err != nil {
124				return fmt.Errorf("failed to build trailers: %w", err)
125			}
126			commitMsg.WriteString("\n\n")
127			commitMsg.WriteString(trailersBlock)
128		}
129
130		gitArgs := []string{"commit"}
131		if add {
132			gitArgs = append(gitArgs, "-a")
133		}
134		if amend {
135			gitArgs = append(gitArgs, "--amend")
136		}
137		gitArgs = append(gitArgs, "-F", "-")
138
139		return runGitWithStdin(gitArgs, commitMsg.String())
140	},
141}
142
143func init() {
144	commitCmd.Flags().StringVarP(&commitType, "type", "t", "", "commit type (required)")
145	commitCmd.Flags().StringVarP(&message, "message", "m", "", "commit message (required)")
146	commitCmd.Flags().StringArrayVarP(&trailers, "trailer", "T", []string{}, "trailer in 'Sentence-case-key: value' format (optional, repeatable)")
147	commitCmd.Flags().StringVarP(&body, "body", "b", "", "commit body (optional)")
148	commitCmd.Flags().StringVarP(&scope, "scope", "s", "", "commit scope (optional)")
149	commitCmd.Flags().StringVarP(&breakingChange, "breaking", "B", "", "breaking change description (optional)")
150	commitCmd.Flags().BoolVarP(&add, "add", "a", false, "stage all modified files before committing (optional)")
151	commitCmd.Flags().BoolVar(&amend, "amend", false, "amend the previous commit (optional)")
152
153	if err := commitCmd.MarkFlagRequired("type"); err != nil {
154		panic(err)
155	}
156	if err := commitCmd.MarkFlagRequired("message"); err != nil {
157		panic(err)
158	}
159
160	rootCmd.AddCommand(installCmd)
161}
162
163func buildAndValidateSubject(commitType, scope, message string, breaking string) (string, error) {
164	var subject strings.Builder
165
166	subject.WriteString(commitType)
167
168	if scope != "" {
169		subject.WriteString("(")
170		subject.WriteString(scope)
171		subject.WriteString(")")
172	}
173
174	if breaking != "" {
175		subject.WriteString("!")
176	}
177
178	subject.WriteString(": ")
179	subject.WriteString(message)
180
181	result := subject.String()
182	if err := validateSubjectLength(result); err != nil {
183		return "", err
184	}
185	return result, nil
186}
187
188func main() {
189	ctx := context.Background()
190
191	var version string
192	if info, ok := debug.ReadBuildInfo(); ok {
193		version = info.Main.Version
194	}
195	if version == "" || version == "(devel)" {
196		version = "dev"
197	}
198
199	var cmd *cobra.Command
200	switch detectMode() {
201	case modeCommit:
202		cmd = commitCmd
203	case modeTag:
204		cmd = tagCmd
205	case modeRoot:
206		cmd = rootCmd
207	default:
208		fmt.Fprintln(os.Stderr, "Error: git-format must be invoked via symlink or as 'git-format'")
209		fmt.Fprintln(os.Stderr, "")
210		fmt.Fprintln(os.Stderr, "Run 'git-format install' to create:")
211		fmt.Fprintln(os.Stderr, "  ~/.local/bin/git-formatted-commit")
212		fmt.Fprintln(os.Stderr, "  ~/.local/bin/git-formatted-tag")
213		fmt.Fprintln(os.Stderr, "")
214		fmt.Fprintln(os.Stderr, "Then use: git formatted-commit ... or git formatted-tag ...")
215		os.Exit(1)
216	}
217
218	if err := fang.Execute(ctx, cmd,
219		fang.WithVersion(version),
220		fang.WithoutCompletions(),
221	); err != nil {
222		os.Exit(1)
223	}
224}