diff --git a/.gitignore b/.gitignore index d6ccfbe..2b71262 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ # SPDX-License-Identifier: CC0-1.0 formatted-commit +git-format diff --git a/AGENTS.md b/AGENTS.md index 6543a61..4914f0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ This file provides guidance to AI coding agents when working with code in this r ## Development Commands - **Default workflow**: `task` (runs fmt, lint, staticcheck, test, vuln, reuse) -- **Build**: `task build` (outputs binary as `formatted-commit`) +- **Build**: `task build` (outputs binary as `git-format`) - **Run during development**: `task run -- [flags]` - **Format code**: `task fmt` (uses gofumpt) - **Lint**: `task lint` (uses golangci-lint) diff --git a/README.md b/README.md index b6679ae..a816ec7 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,10 @@ SPDX-FileCopyrightText: Amolith SPDX-License-Identifier: CC0-1.0 --> -# formatted-commit +# git-format -[![Go Report Card](https://goreportcard.com/badge/git.secluded.site/formatted-commit)](https://goreportcard.com/report/git.secluded.site/formatted-commit) -[![REUSE status](https://api.reuse.software/badge/git.secluded.site/formatted-commit)](https://api.reuse.software/info/git.secluded.site/formatted-commit) +[![Go Report Card](https://goreportcard.com/badge/git.secluded.site/git-format)](https://goreportcard.com/report/git.secluded.site/git-format) +[![REUSE status](https://api.reuse.software/badge/git.secluded.site/git-format)](https://api.reuse.software/info/git.secluded.site/git-format) [![Liberapay donation status](https://img.shields.io/liberapay/receives/Amolith.svg?logo=liberapay)](https://liberapay.com/Amolith/) CLI tool that produces commits following the Conventional Commits specification @@ -24,65 +24,36 @@ I've found that LLMs consistently fail to kernel. I try to stick to it, but LLMs writing badly-formatted commit messages means rewording work for me once they're done. -`formatted-commit` enforces all of this. Where possible, we "correct" the -model's input. Where that's less possible, we error. For example, instead of -requiring the model wrap body text at 72 columns, we let it write whatever body -it wants and wrap it ourselves. We can't really fix the subject, so -`formatted-commit` emits an error when the subject is too long with clear -indication of where the 50-character cut-off is. +`git-format` enforces all of this. Where possible, we "correct" the model's +input. Where that's less possible, we error. For example, instead of requiring +the model wrap body text at 72 columns, we let it write whatever body it wants +and wrap it ourselves. We can't really fix the subject, so `git-format` emits an +error when the subject is too long with clear indication of where the +50-character cut-off is. ## Installation -You need _both_ the binary and the prompt. +You need _both_ the binary and [the skill]. + +[the skill]: https://git.secluded.site/agent-skills#:~:text=formatting%2Dcommits%3A%20Creates%20commits%20strictly%20following%20Conventional%20Commits%20format%20via%20the%20formatted%2Dcommit%20CLI ### The binary - Using [bin](https://github.com/marcosnils/bin) (highly recommended because it's one tool to manage and update myriad CLI tools - distributed as statically-linked binaries, like _formatted-commit_) + distributed as statically-linked binaries, like _git-format_) ```bash - bin install goinstall://git.secluded.site/formatted-commit@latest + bin install goinstall://git.secluded.site/git-format@latest + git-format install ``` -- Using the [go toolchain](https://go.dev/dl) (upgrade with `formatted-commit upgrade`) +- Using the [go toolchain](https://go.dev/dl) (upgrade with `git-format upgrade`) ```bash - go install git.secluded.site/formatted-commit@latest + go install git.secluded.site/git-format@latest + git-format install ``` -### The prompt - -Paste this snippet into some section of your `~/.config/AGENTS.md` or -`~/.claude/CLAUDE.md` or whatever titled something like `## Creating git -commits`. - -```markdown -Create/amend commits exclusively using `formatted-commit`. It has no sub-commands and the following options: - --t --type Commit type (required) --s --scope Commit scope (optional) --B --breaking Mark as breaking change (optional) --m --message Commit message (required) --b --body Commit body (optional) --T --trailer Trailer in 'Sentence-case-key: value' format (optional, repeatable) --a --add Stage all modified files before committing (optional) ---amend Amend the previous commit (optional) --h --help - - -formatted-commit -t feat -s "web/git-bug" -m "do a fancy new thing" -T "Assisted-by: GLM 4.6 via Crush" -b "$(cat <<'EOF' -Multi-line - -- Body -- Here - -EOF -)" - -``` - -## Changelog - -See [CHANGELOG.md](CHANGELOG.md) for release notes and version history. -(Maintained by [kittylog](https://github.com/cellwebb/kittylog)) +The `git-format install` command creates symlinks so you can invoke it as `git +formatted-commit` and `git formatted-tag`. ## Contributions diff --git a/Taskfile.yaml b/Taskfile.yaml index e353442..2b3f235 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -60,39 +60,39 @@ tasks: - reuse lint build: - desc: Build formatted-commit + desc: Build git-format cmds: - - go build -o formatted-commit -ldflags "-s -w -X main.version={{.VERSION}}" + - go build -o git-format -ldflags "-s -w -X main.version={{.VERSION}}" generates: - - formatted-commit + - git-format install: - desc: Install formatted-commit + desc: Install git-format cmds: - go install -ldflags "-s -w -X main.version={{.VERSION}}" run: - desc: Run formatted-commit + desc: Run git-format cmds: - go run -ldflags "-s -w -X main.version={{.VERSION}}" . {{.CLI_ARGS}} pack: - desc: Pack formatted-commit with UPX + desc: Pack git-format with UPX cmds: - - upx --best -qo formatted-commit.min formatted-commit - - mv formatted-commit.min formatted-commit + - upx --best -qo git-format.min git-format + - mv git-format.min git-format sources: - - formatted-commit + - git-format clean: desc: Remove build artifacts cmds: - - rm -rf formatted-commit + - rm -rf git-format clean-all: desc: Remove build artifacts and config.toml cmds: - - rm -rf formatted-commit config.toml + - rm -rf git-format config.toml release: desc: Interactive release workflow @@ -129,5 +129,5 @@ tasks: llm-tag {{.NEXT}} fi - git push soft {{.NEXT}} - - go list -m git.secluded.site/formatted-commit@{{.NEXT}} > /dev/null + - go list -m git.secluded.site/git-format@{{.NEXT}} > /dev/null - echo "Released {{.NEXT}} and notified module proxy" diff --git a/git.go b/git.go new file mode 100644 index 0000000..c4d3a67 --- /dev/null +++ b/git.go @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "fmt" + "os" + "os/exec" +) + +func runGitWithStdin(args []string, content string) error { + gitCmd := exec.Command("git", args...) + gitCmd.Stdout = os.Stdout + gitCmd.Stderr = os.Stderr + + stdin, err := gitCmd.StdinPipe() + if err != nil { + return fmt.Errorf("failed to create stdin pipe: %w", err) + } + + if err := gitCmd.Start(); err != nil { + return fmt.Errorf("failed to start git command: %w", err) + } + + if _, err := stdin.Write([]byte(content)); err != nil { + return fmt.Errorf("failed to write to git stdin: %w", err) + } + + if err := stdin.Close(); err != nil { + return fmt.Errorf("failed to close stdin: %w", err) + } + + if err := gitCmd.Wait(); err != nil { + return fmt.Errorf("git %s failed: %w", args[0], err) + } + + return nil +} + +func validateSubjectLength(subject string) error { + length := len(subject) + if length > 50 { + exceededBy := length - 50 + truncated := subject[:50] + "…" + return fmt.Errorf("subject exceeds 50 character limit by %d:\n%s", exceededBy, truncated) + } + return nil +} diff --git a/go.mod b/go.mod index ad80cee..ce12751 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: CC0-1.0 -module git.secluded.site/formatted-commit +module git.secluded.site/git-format go 1.25.3 @@ -14,8 +14,8 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/microcosm-cc/bluemonday v1.0.27 github.com/spf13/cobra v1.10.1 - golang.org/x/mod v0.17.0 - golang.org/x/term v0.30.0 + golang.org/x/mod v0.31.0 + golang.org/x/term v0.39.0 ) require ( @@ -52,8 +52,8 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/net v0.26.0 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/text v0.24.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect ) diff --git a/go.sum b/go.sum index c25c3a6..4a5c771 100644 --- a/go.sum +++ b/go.sum @@ -103,20 +103,20 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= -golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= -golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/install.go b/install.go new file mode 100644 index 0000000..1febac4 --- /dev/null +++ b/install.go @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" +) + +var installCmd = &cobra.Command{ + Use: "install", + Short: "Create symlinks for git formatted-commit and git formatted-tag", + Long: `Creates symlinks in ~/.local/bin so you can use: + git formatted-commit ... + git formatted-tag ... + +The symlinks point to this binary and dispatch based on the invocation name.`, + RunE: func(cmd *cobra.Command, args []string) error { + return runInstall() + }, +} + +func runInstall() error { + execPath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to determine executable path: %w", err) + } + execPath, err = filepath.EvalSymlinks(execPath) + if err != nil { + return fmt.Errorf("failed to resolve executable path: %w", err) + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to determine home directory: %w", err) + } + + binDir := filepath.Join(homeDir, ".local", "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + return fmt.Errorf("failed to create %s: %w", binDir, err) + } + + symlinks := []struct { + name string + path string + }{ + {"git-formatted-commit", filepath.Join(binDir, "git-formatted-commit")}, + {"git-formatted-tag", filepath.Join(binDir, "git-formatted-tag")}, + } + + for _, link := range symlinks { + if err := createSymlink(execPath, link.path, link.name); err != nil { + return err + } + } + + checkPath(binDir) + return nil +} + +func createSymlink(target, linkPath, name string) error { + info, err := os.Lstat(linkPath) + if err == nil { + if info.Mode()&os.ModeSymlink == 0 { + fmt.Fprintf(os.Stderr, "Error: %s exists and is not a symlink\n", linkPath) + fmt.Fprintln(os.Stderr, " Refusing to overwrite. Remove it manually if intended.") + return fmt.Errorf("cannot overwrite non-symlink at %s", linkPath) + } + + existingTarget, err := os.Readlink(linkPath) + if err != nil { + return fmt.Errorf("failed to read existing symlink %s: %w", linkPath, err) + } + + if existingTarget == target { + fmt.Printf(" %s → %s (already exists, same target)\n", name, target) + return nil + } + + fmt.Printf(" %s exists, points to %s\n", linkPath, existingTarget) + fmt.Printf(" Overwrite to point to %s? [y/N] ", target) + + reader := bufio.NewReader(os.Stdin) + response, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + response = strings.TrimSpace(strings.ToLower(response)) + if response != "y" && response != "yes" { + fmt.Printf(" Skipping %s\n", name) + return nil + } + + if err := os.Remove(linkPath); err != nil { + return fmt.Errorf("failed to remove existing symlink: %w", err) + } + } else if !os.IsNotExist(err) { + return fmt.Errorf("failed to check %s: %w", linkPath, err) + } + + if err := os.Symlink(target, linkPath); err != nil { + return fmt.Errorf("failed to create symlink %s: %w", linkPath, err) + } + + fmt.Printf(" Created %s → %s\n", name, target) + return nil +} + +func checkPath(binDir string) { + pathEnv := os.Getenv("PATH") + paths := filepath.SplitList(pathEnv) + + for _, p := range paths { + if p == binDir { + return + } + } + + fmt.Println() + fmt.Printf("⚠ Warning: %s is not in your PATH\n", binDir) + fmt.Println(" Add this to your shell config:") + fmt.Printf(" export PATH=\"$HOME/.local/bin:$PATH\"\n") +} diff --git a/main.go b/main.go index 6ffe32f..4606feb 100644 --- a/main.go +++ b/main.go @@ -8,7 +8,7 @@ import ( "context" "fmt" "os" - "os/exec" + "path/filepath" "runtime/debug" "strings" @@ -16,6 +16,15 @@ import ( "github.com/spf13/cobra" ) +type mode int + +const ( + modeUnknown mode = iota + modeCommit + modeTag + modeRoot +) + var ( commitType string message string @@ -27,36 +36,60 @@ var ( amend bool ) +func detectMode() mode { + base := filepath.Base(os.Args[0]) + switch { + case strings.HasSuffix(base, "git-formatted-commit"): + return modeCommit + case strings.HasSuffix(base, "git-formatted-tag"): + return modeTag + case strings.HasSuffix(base, "git-format"): + return modeRoot + default: + return modeUnknown + } +} + var rootCmd = &cobra.Command{ - Use: "formatted-commit", + Use: "git-format", + Short: "Format git commits and tags with proper conventions", + Long: `git-format helps you create well-formatted Git commits and tags with proper +subject length validation, body wrapping, and trailer formatting. + +This tool is meant to be invoked via symlinks: + git formatted-commit - Create conventionally formatted commits + git formatted-tag - Create formatted annotated tags + +Run 'git-format install' to create the symlinks.`, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, +} + +var commitCmd = &cobra.Command{ + Use: "git-formatted-commit", Short: "Create conventionally formatted Git commits", - Long: `formatted-commit helps you create well-formatted Git commits that follow + Long: `git-formatted-commit helps you create well-formatted Git commits that follow the Conventional Commits specification with proper subject length validation, body wrapping, and trailer formatting.`, Example: ` # With Assisted-by -formatted-commit -t feat -m "do a thing" -T "Assisted-by: GLM 4.6 via Crush" +git formatted-commit -t feat -m "do a thing" -T "Assisted-by: GLM 4.6 via Crush" # Breaking change with description -formatted-commit -t feat -m "remove deprecated API" \ +git formatted-commit -t feat -m "remove deprecated API" \ -B "The old /v1/users endpoint is removed. Use /v2/users instead." # Breaking change with multi-line description using heredoc -formatted-commit -t feat -m "restructure config format" -B "$(cat <<'EOF' +git formatted-commit -t feat -m "restructure config format" -B "$(cat <<'EOF' Configuration format changed from JSON to TOML. Migrate by running: ./migrate-config.sh EOF )" -b "Improves readability and supports comments" # Including scope for more precise changes -formatted-commit -t refactor -s "web/git-bug" -m "fancy shmancy" \ +git formatted-commit -t refactor -s "web/git-bug" -m "fancy shmancy" \ -b "Had to do a weird thing because..." - -# Check for upgrades -formatted-commit upgrade - -# Then apply -formatted-commit upgrade -a `, RunE: func(cmd *cobra.Command, args []string) error { subject, err := buildAndValidateSubject(commitType, scope, message, breakingChange) @@ -103,51 +136,28 @@ formatted-commit upgrade -a } gitArgs = append(gitArgs, "-F", "-") - gitCmd := exec.Command("git", gitArgs...) - gitCmd.Stdout = os.Stdout - gitCmd.Stderr = os.Stderr - - stdin, err := gitCmd.StdinPipe() - if err != nil { - return fmt.Errorf("failed to create stdin pipe: %w", err) - } - - if err := gitCmd.Start(); err != nil { - return fmt.Errorf("failed to start git command: %w", err) - } - - if _, err := stdin.Write([]byte(commitMsg.String())); err != nil { - return fmt.Errorf("failed to write to git stdin: %w", err) - } - - if err := stdin.Close(); err != nil { - return fmt.Errorf("failed to close stdin: %w", err) - } - - if err := gitCmd.Wait(); err != nil { - return fmt.Errorf("git commit failed: %w", err) - } - - return nil + return runGitWithStdin(gitArgs, commitMsg.String()) }, } func init() { - rootCmd.Flags().StringVarP(&commitType, "type", "t", "", "commit type (required)") - rootCmd.Flags().StringVarP(&message, "message", "m", "", "commit message (required)") - rootCmd.Flags().StringArrayVarP(&trailers, "trailer", "T", []string{}, "trailer in 'Sentence-case-key: value' format (optional, repeatable)") - rootCmd.Flags().StringVarP(&body, "body", "b", "", "commit body (optional)") - rootCmd.Flags().StringVarP(&scope, "scope", "s", "", "commit scope (optional)") - rootCmd.Flags().StringVarP(&breakingChange, "breaking", "B", "", "breaking change description (optional)") - rootCmd.Flags().BoolVarP(&add, "add", "a", false, "stage all modified files before committing (optional)") - rootCmd.Flags().BoolVar(&amend, "amend", false, "amend the previous commit (optional)") - - if err := rootCmd.MarkFlagRequired("type"); err != nil { + commitCmd.Flags().StringVarP(&commitType, "type", "t", "", "commit type (required)") + commitCmd.Flags().StringVarP(&message, "message", "m", "", "commit message (required)") + commitCmd.Flags().StringArrayVarP(&trailers, "trailer", "T", []string{}, "trailer in 'Sentence-case-key: value' format (optional, repeatable)") + commitCmd.Flags().StringVarP(&body, "body", "b", "", "commit body (optional)") + commitCmd.Flags().StringVarP(&scope, "scope", "s", "", "commit scope (optional)") + commitCmd.Flags().StringVarP(&breakingChange, "breaking", "B", "", "breaking change description (optional)") + commitCmd.Flags().BoolVarP(&add, "add", "a", false, "stage all modified files before committing (optional)") + commitCmd.Flags().BoolVar(&amend, "amend", false, "amend the previous commit (optional)") + + if err := commitCmd.MarkFlagRequired("type"); err != nil { panic(err) } - if err := rootCmd.MarkFlagRequired("message"); err != nil { + if err := commitCmd.MarkFlagRequired("message"); err != nil { panic(err) } + + rootCmd.AddCommand(installCmd) } func buildAndValidateSubject(commitType, scope, message string, breaking string) (string, error) { @@ -169,14 +179,9 @@ func buildAndValidateSubject(commitType, scope, message string, breaking string) subject.WriteString(message) result := subject.String() - length := len(result) - - if length > 50 { - exceededBy := length - 50 - truncated := result[:50] + "…" - return "", fmt.Errorf("subject exceeds 50 character limit by %d:\n%s", exceededBy, truncated) + if err := validateSubjectLength(result); err != nil { + return "", err } - return result, nil } @@ -191,7 +196,26 @@ func main() { version = "dev" } - if err := fang.Execute(ctx, rootCmd, + var cmd *cobra.Command + switch detectMode() { + case modeCommit: + cmd = commitCmd + case modeTag: + cmd = tagCmd + case modeRoot: + cmd = rootCmd + default: + fmt.Fprintln(os.Stderr, "Error: git-format must be invoked via symlink or as 'git-format'") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Run 'git-format install' to create:") + fmt.Fprintln(os.Stderr, " ~/.local/bin/git-formatted-commit") + fmt.Fprintln(os.Stderr, " ~/.local/bin/git-formatted-tag") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Then use: git formatted-commit ... or git formatted-tag ...") + os.Exit(1) + } + + if err := fang.Execute(ctx, cmd, fang.WithVersion(version), fang.WithoutCompletions(), ); err != nil { diff --git a/tag.go b/tag.go new file mode 100644 index 0000000..efc323d --- /dev/null +++ b/tag.go @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +var ( + tagMessage string + tagBody string + tagForce bool +) + +var tagCmd = &cobra.Command{ + Use: "git-formatted-tag NAME", + Short: "Create formatted annotated Git tags", + Long: `git-formatted-tag helps you create well-formatted annotated Git tags with +proper subject length validation and body wrapping.`, + Example: ` +# Simple tag with just a message +git formatted-tag v1.0.0 -m "Initial stable release" + +# Tag with body +git formatted-tag v1.2.0 -m "Add user authentication" -b "$(cat <<'EOF' +✨ Features +- [auth]: implement JWT-based authentication +- [api]: add login and logout endpoints + +🐛 Bug Fixes +- [login]: correct password validation error +EOF +)" + +# Force overwrite an existing tag +git formatted-tag v1.0.0 -m "Re-release with hotfix" -f +`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + tagName := args[0] + + if tagMessage == "" { + return fmt.Errorf("formatted-tag requires at least a subject (-m)\nFor lightweight tags, use: git tag %s", tagName) + } + + if err := validateSubjectLength(tagMessage); err != nil { + return err + } + + var tagContent strings.Builder + tagContent.WriteString(tagMessage) + + if tagBody != "" { + formattedBody, err := formatBody(tagBody) + if err != nil { + return fmt.Errorf("failed to format body: %w", err) + } + tagContent.WriteString("\n\n") + tagContent.WriteString(formattedBody) + } + + gitArgs := []string{"tag", "-a", tagName} + if tagForce { + gitArgs = append(gitArgs, "-f") + } + gitArgs = append(gitArgs, "-F", "-") + + return runGitWithStdin(gitArgs, tagContent.String()) + }, +} + +func init() { + tagCmd.Flags().StringVarP(&tagMessage, "message", "m", "", "tag subject (required, max 50 characters)") + tagCmd.Flags().StringVarP(&tagBody, "body", "b", "", "tag body (optional)") + tagCmd.Flags().BoolVarP(&tagForce, "force", "f", false, "replace existing tag") +} diff --git a/upgrade.go b/upgrade.go index cd7b72b..cc7ea00 100644 --- a/upgrade.go +++ b/upgrade.go @@ -119,7 +119,7 @@ func runUpgrade(apply bool) error { } if !apply { - fmt.Printf("\nRun %s to apply the upgrade\n", cmdStyle.Render("formatted-commit upgrade -a")) + fmt.Printf("\nRun %s to apply the upgrade\n", cmdStyle.Render("git-format upgrade -a")) return nil }