.gitignore 🔗
@@ -3,3 +3,4 @@
# SPDX-License-Identifier: CC0-1.0
formatted-commit
+git-format
Amolith created
Extends the tool to support both commit and tag formatting:
- Add git-formatted-tag mode for annotated tag formatting
- Add install subcommand to create symlinks in ~/.local/bin
- Extract shared code (runGitWithStdin, validateSubjectLength) to git.go
- Update module path to git.secluded.site/git-format
BREAKING CHANGE: Renamed from formatted-commit to git-format. Uninstall
the old binary and reinstall following README instructions. Run
git-format install to create symlinks, then invoke via git
formatted-commit or git formatted-tag.
.gitignore | 1
AGENTS.md | 2
README.md | 69 +++++++------------------
Taskfile.yaml | 24 ++++----
git.go | 50 ++++++++++++++++++
go.mod | 14 ++--
go.sum | 24 ++++----
install.go | 131 +++++++++++++++++++++++++++++++++++++++++++++++++
main.go | 140 +++++++++++++++++++++++++++++++---------------------
tag.go | 81 ++++++++++++++++++++++++++++++
upgrade.go | 2
11 files changed, 398 insertions(+), 140 deletions(-)
@@ -3,3 +3,4 @@
# SPDX-License-Identifier: CC0-1.0
formatted-commit
+git-format
@@ -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)
@@ -4,10 +4,10 @@ SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
SPDX-License-Identifier: CC0-1.0
-->
-# formatted-commit
+# git-format
-[](https://goreportcard.com/report/git.secluded.site/formatted-commit)
-[](https://api.reuse.software/info/git.secluded.site/formatted-commit)
+[](https://goreportcard.com/report/git.secluded.site/git-format)
+[](https://api.reuse.software/info/git.secluded.site/git-format)
[](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:
-<formatted-commit_flags>
--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_flags>
-<formatted-commit_example>
-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
-)"
-</formatted-commit_example>
-```
-
-## 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
@@ -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"
@@ -0,0 +1,50 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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
+}
@@ -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
)
@@ -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=
@@ -0,0 +1,131 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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")
+}
@@ -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 {
@@ -0,0 +1,81 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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")
+}
@@ -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
}