feat!: add tag mode and rename to 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.

Change summary

.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(-)

Detailed changes

.gitignore 🔗

@@ -3,3 +3,4 @@
 # SPDX-License-Identifier: CC0-1.0
 
 formatted-commit
+git-format

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)

README.md 🔗

@@ -4,10 +4,10 @@ SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
 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:
-<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
 

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"

git.go 🔗

@@ -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
+}

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
 )

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=

install.go 🔗

@@ -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")
+}

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 {

tag.go 🔗

@@ -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")
+}

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
 	}