CLI that turns LLM input into well-formatted Conventional Commits
0

Configure Feed

Select the types of activity you want to include in your feed.

feat!: add tag mode and rename to git-format

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.

Amolith (Feb 4, 2026, 2:04 PM -0700) d1864a91 395559b5

+397 -139
+1
.gitignore
··· 3 3 # SPDX-License-Identifier: CC0-1.0 4 4 5 5 formatted-commit 6 + git-format
+1 -1
AGENTS.md
··· 11 11 ## Development Commands 12 12 13 13 - **Default workflow**: `task` (runs fmt, lint, staticcheck, test, vuln, reuse) 14 - - **Build**: `task build` (outputs binary as `formatted-commit`) 14 + - **Build**: `task build` (outputs binary as `git-format`) 15 15 - **Run during development**: `task run -- [flags]` 16 16 - **Format code**: `task fmt` (uses gofumpt) 17 17 - **Lint**: `task lint` (uses golangci-lint)
+20 -49
README.md
··· 4 4 SPDX-License-Identifier: CC0-1.0 5 5 --> 6 6 7 - # formatted-commit 7 + # git-format 8 8 9 - [![Go Report Card](https://goreportcard.com/badge/git.secluded.site/formatted-commit)](https://goreportcard.com/report/git.secluded.site/formatted-commit) 10 - [![REUSE status](https://api.reuse.software/badge/git.secluded.site/formatted-commit)](https://api.reuse.software/info/git.secluded.site/formatted-commit) 9 + [![Go Report Card](https://goreportcard.com/badge/git.secluded.site/git-format)](https://goreportcard.com/report/git.secluded.site/git-format) 10 + [![REUSE status](https://api.reuse.software/badge/git.secluded.site/git-format)](https://api.reuse.software/info/git.secluded.site/git-format) 11 11 [![Liberapay donation status](https://img.shields.io/liberapay/receives/Amolith.svg?logo=liberapay)](https://liberapay.com/Amolith/) 12 12 13 13 CLI tool that produces commits following the Conventional Commits specification ··· 24 24 kernel. I try to stick to it, but LLMs writing badly-formatted commit messages 25 25 means rewording work for me once they're done. 26 26 27 - `formatted-commit` enforces all of this. Where possible, we "correct" the 28 - model's input. Where that's less possible, we error. For example, instead of 29 - requiring the model wrap body text at 72 columns, we let it write whatever body 30 - it wants and wrap it ourselves. We can't really fix the subject, so 31 - `formatted-commit` emits an error when the subject is too long with clear 32 - indication of where the 50-character cut-off is. 27 + `git-format` enforces all of this. Where possible, we "correct" the model's 28 + input. Where that's less possible, we error. For example, instead of requiring 29 + the model wrap body text at 72 columns, we let it write whatever body it wants 30 + and wrap it ourselves. We can't really fix the subject, so `git-format` emits an 31 + error when the subject is too long with clear indication of where the 32 + 50-character cut-off is. 33 33 34 34 ## Installation 35 35 36 - You need _both_ the binary and the prompt. 36 + You need _both_ the binary and [the skill]. 37 + 38 + [the skill]: https://git.secluded.site/agent-skills#:~:text=formatting%2Dcommits%3A%20Creates%20commits%20strictly%20following%20Conventional%20Commits%20format%20via%20the%20formatted%2Dcommit%20CLI 37 39 38 40 ### The binary 39 41 40 42 - Using [bin](https://github.com/marcosnils/bin) (highly recommended 41 43 because it's one tool to manage and update myriad CLI tools 42 - distributed as statically-linked binaries, like _formatted-commit_) 44 + distributed as statically-linked binaries, like _git-format_) 43 45 ```bash 44 - bin install goinstall://git.secluded.site/formatted-commit@latest 46 + bin install goinstall://git.secluded.site/git-format@latest 47 + git-format install 45 48 ``` 46 - - Using the [go toolchain](https://go.dev/dl) (upgrade with `formatted-commit upgrade`) 49 + - Using the [go toolchain](https://go.dev/dl) (upgrade with `git-format upgrade`) 47 50 ```bash 48 - go install git.secluded.site/formatted-commit@latest 51 + go install git.secluded.site/git-format@latest 52 + git-format install 49 53 ``` 50 54 51 - ### The prompt 52 - 53 - Paste this snippet into some section of your `~/.config/AGENTS.md` or 54 - `~/.claude/CLAUDE.md` or whatever titled something like `## Creating git 55 - commits`. 56 - 57 - ```markdown 58 - Create/amend commits exclusively using `formatted-commit`. It has no sub-commands and the following options: 59 - <formatted-commit_flags> 60 - -t --type Commit type (required) 61 - -s --scope Commit scope (optional) 62 - -B --breaking Mark as breaking change (optional) 63 - -m --message Commit message (required) 64 - -b --body Commit body (optional) 65 - -T --trailer Trailer in 'Sentence-case-key: value' format (optional, repeatable) 66 - -a --add Stage all modified files before committing (optional) 67 - --amend Amend the previous commit (optional) 68 - -h --help 69 - </formatted-commit_flags> 70 - <formatted-commit_example> 71 - 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' 72 - Multi-line 73 - 74 - - Body 75 - - Here 76 - 77 - EOF 78 - )" 79 - </formatted-commit_example> 80 - ``` 81 - 82 - ## Changelog 83 - 84 - See [CHANGELOG.md](CHANGELOG.md) for release notes and version history. 85 - (Maintained by [kittylog](https://github.com/cellwebb/kittylog)) 55 + The `git-format install` command creates symlinks so you can invoke it as `git 56 + formatted-commit` and `git formatted-tag`. 86 57 87 58 ## Contributions 88 59
+12 -12
Taskfile.yaml
··· 60 60 - reuse lint 61 61 62 62 build: 63 - desc: Build formatted-commit 63 + desc: Build git-format 64 64 cmds: 65 - - go build -o formatted-commit -ldflags "-s -w -X main.version={{.VERSION}}" 65 + - go build -o git-format -ldflags "-s -w -X main.version={{.VERSION}}" 66 66 generates: 67 - - formatted-commit 67 + - git-format 68 68 69 69 install: 70 - desc: Install formatted-commit 70 + desc: Install git-format 71 71 cmds: 72 72 - go install -ldflags "-s -w -X main.version={{.VERSION}}" 73 73 74 74 run: 75 - desc: Run formatted-commit 75 + desc: Run git-format 76 76 cmds: 77 77 - go run -ldflags "-s -w -X main.version={{.VERSION}}" . {{.CLI_ARGS}} 78 78 79 79 pack: 80 - desc: Pack formatted-commit with UPX 80 + desc: Pack git-format with UPX 81 81 cmds: 82 - - upx --best -qo formatted-commit.min formatted-commit 83 - - mv formatted-commit.min formatted-commit 82 + - upx --best -qo git-format.min git-format 83 + - mv git-format.min git-format 84 84 sources: 85 - - formatted-commit 85 + - git-format 86 86 87 87 clean: 88 88 desc: Remove build artifacts 89 89 cmds: 90 - - rm -rf formatted-commit 90 + - rm -rf git-format 91 91 92 92 clean-all: 93 93 desc: Remove build artifacts and config.toml 94 94 cmds: 95 - - rm -rf formatted-commit config.toml 95 + - rm -rf git-format config.toml 96 96 97 97 release: 98 98 desc: Interactive release workflow ··· 129 129 llm-tag {{.NEXT}} 130 130 fi 131 131 - git push soft {{.NEXT}} 132 - - go list -m git.secluded.site/formatted-commit@{{.NEXT}} > /dev/null 132 + - go list -m git.secluded.site/git-format@{{.NEXT}} > /dev/null 133 133 - echo "Released {{.NEXT}} and notified module proxy"
+50
git.go
··· 1 + // SPDX-FileCopyrightText: Amolith <amolith@secluded.site> 2 + // 3 + // SPDX-License-Identifier: AGPL-3.0-or-later 4 + 5 + package main 6 + 7 + import ( 8 + "fmt" 9 + "os" 10 + "os/exec" 11 + ) 12 + 13 + func runGitWithStdin(args []string, content string) error { 14 + gitCmd := exec.Command("git", args...) 15 + gitCmd.Stdout = os.Stdout 16 + gitCmd.Stderr = os.Stderr 17 + 18 + stdin, err := gitCmd.StdinPipe() 19 + if err != nil { 20 + return fmt.Errorf("failed to create stdin pipe: %w", err) 21 + } 22 + 23 + if err := gitCmd.Start(); err != nil { 24 + return fmt.Errorf("failed to start git command: %w", err) 25 + } 26 + 27 + if _, err := stdin.Write([]byte(content)); err != nil { 28 + return fmt.Errorf("failed to write to git stdin: %w", err) 29 + } 30 + 31 + if err := stdin.Close(); err != nil { 32 + return fmt.Errorf("failed to close stdin: %w", err) 33 + } 34 + 35 + if err := gitCmd.Wait(); err != nil { 36 + return fmt.Errorf("git %s failed: %w", args[0], err) 37 + } 38 + 39 + return nil 40 + } 41 + 42 + func validateSubjectLength(subject string) error { 43 + length := len(subject) 44 + if length > 50 { 45 + exceededBy := length - 50 46 + truncated := subject[:50] + "…" 47 + return fmt.Errorf("subject exceeds 50 character limit by %d:\n%s", exceededBy, truncated) 48 + } 49 + return nil 50 + }
+7 -7
go.mod
··· 2 2 // 3 3 // SPDX-License-Identifier: CC0-1.0 4 4 5 - module git.secluded.site/formatted-commit 5 + module git.secluded.site/git-format 6 6 7 7 go 1.25.3 8 8 ··· 14 14 github.com/charmbracelet/lipgloss v1.1.0 15 15 github.com/microcosm-cc/bluemonday v1.0.27 16 16 github.com/spf13/cobra v1.10.1 17 - golang.org/x/mod v0.17.0 18 - golang.org/x/term v0.30.0 17 + golang.org/x/mod v0.31.0 18 + golang.org/x/term v0.39.0 19 19 ) 20 20 21 21 require ( ··· 52 52 github.com/rivo/uniseg v0.4.7 // indirect 53 53 github.com/spf13/pflag v1.0.9 // indirect 54 54 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 55 - golang.org/x/net v0.26.0 // indirect 56 - golang.org/x/sync v0.17.0 // indirect 57 - golang.org/x/sys v0.36.0 // indirect 58 - golang.org/x/text v0.24.0 // indirect 55 + golang.org/x/net v0.49.0 // indirect 56 + golang.org/x/sync v0.19.0 // indirect 57 + golang.org/x/sys v0.40.0 // indirect 58 + golang.org/x/text v0.33.0 // indirect 59 59 )
+12 -12
go.sum
··· 103 103 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 104 104 golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= 105 105 golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 106 - golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= 107 - golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 108 - golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= 109 - golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= 110 - golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= 111 - golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 106 + golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= 107 + golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= 108 + golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= 109 + golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= 110 + golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= 111 + golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 112 112 golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 113 113 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 114 - golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= 115 - golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 116 - golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= 117 - golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= 118 - golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 119 - golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 114 + golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= 115 + golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 116 + golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= 117 + golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= 118 + golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= 119 + golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= 120 120 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 121 121 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 122 122 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+131
install.go
··· 1 + // SPDX-FileCopyrightText: Amolith <amolith@secluded.site> 2 + // 3 + // SPDX-License-Identifier: AGPL-3.0-or-later 4 + 5 + package main 6 + 7 + import ( 8 + "bufio" 9 + "fmt" 10 + "os" 11 + "path/filepath" 12 + "strings" 13 + 14 + "github.com/spf13/cobra" 15 + ) 16 + 17 + var installCmd = &cobra.Command{ 18 + Use: "install", 19 + Short: "Create symlinks for git formatted-commit and git formatted-tag", 20 + Long: `Creates symlinks in ~/.local/bin so you can use: 21 + git formatted-commit ... 22 + git formatted-tag ... 23 + 24 + The symlinks point to this binary and dispatch based on the invocation name.`, 25 + RunE: func(cmd *cobra.Command, args []string) error { 26 + return runInstall() 27 + }, 28 + } 29 + 30 + func runInstall() error { 31 + execPath, err := os.Executable() 32 + if err != nil { 33 + return fmt.Errorf("failed to determine executable path: %w", err) 34 + } 35 + execPath, err = filepath.EvalSymlinks(execPath) 36 + if err != nil { 37 + return fmt.Errorf("failed to resolve executable path: %w", err) 38 + } 39 + 40 + homeDir, err := os.UserHomeDir() 41 + if err != nil { 42 + return fmt.Errorf("failed to determine home directory: %w", err) 43 + } 44 + 45 + binDir := filepath.Join(homeDir, ".local", "bin") 46 + if err := os.MkdirAll(binDir, 0o755); err != nil { 47 + return fmt.Errorf("failed to create %s: %w", binDir, err) 48 + } 49 + 50 + symlinks := []struct { 51 + name string 52 + path string 53 + }{ 54 + {"git-formatted-commit", filepath.Join(binDir, "git-formatted-commit")}, 55 + {"git-formatted-tag", filepath.Join(binDir, "git-formatted-tag")}, 56 + } 57 + 58 + for _, link := range symlinks { 59 + if err := createSymlink(execPath, link.path, link.name); err != nil { 60 + return err 61 + } 62 + } 63 + 64 + checkPath(binDir) 65 + return nil 66 + } 67 + 68 + func createSymlink(target, linkPath, name string) error { 69 + info, err := os.Lstat(linkPath) 70 + if err == nil { 71 + if info.Mode()&os.ModeSymlink == 0 { 72 + fmt.Fprintf(os.Stderr, "Error: %s exists and is not a symlink\n", linkPath) 73 + fmt.Fprintln(os.Stderr, " Refusing to overwrite. Remove it manually if intended.") 74 + return fmt.Errorf("cannot overwrite non-symlink at %s", linkPath) 75 + } 76 + 77 + existingTarget, err := os.Readlink(linkPath) 78 + if err != nil { 79 + return fmt.Errorf("failed to read existing symlink %s: %w", linkPath, err) 80 + } 81 + 82 + if existingTarget == target { 83 + fmt.Printf(" %s → %s (already exists, same target)\n", name, target) 84 + return nil 85 + } 86 + 87 + fmt.Printf(" %s exists, points to %s\n", linkPath, existingTarget) 88 + fmt.Printf(" Overwrite to point to %s? [y/N] ", target) 89 + 90 + reader := bufio.NewReader(os.Stdin) 91 + response, err := reader.ReadString('\n') 92 + if err != nil { 93 + return fmt.Errorf("failed to read response: %w", err) 94 + } 95 + 96 + response = strings.TrimSpace(strings.ToLower(response)) 97 + if response != "y" && response != "yes" { 98 + fmt.Printf(" Skipping %s\n", name) 99 + return nil 100 + } 101 + 102 + if err := os.Remove(linkPath); err != nil { 103 + return fmt.Errorf("failed to remove existing symlink: %w", err) 104 + } 105 + } else if !os.IsNotExist(err) { 106 + return fmt.Errorf("failed to check %s: %w", linkPath, err) 107 + } 108 + 109 + if err := os.Symlink(target, linkPath); err != nil { 110 + return fmt.Errorf("failed to create symlink %s: %w", linkPath, err) 111 + } 112 + 113 + fmt.Printf(" Created %s → %s\n", name, target) 114 + return nil 115 + } 116 + 117 + func checkPath(binDir string) { 118 + pathEnv := os.Getenv("PATH") 119 + paths := filepath.SplitList(pathEnv) 120 + 121 + for _, p := range paths { 122 + if p == binDir { 123 + return 124 + } 125 + } 126 + 127 + fmt.Println() 128 + fmt.Printf("⚠ Warning: %s is not in your PATH\n", binDir) 129 + fmt.Println(" Add this to your shell config:") 130 + fmt.Printf(" export PATH=\"$HOME/.local/bin:$PATH\"\n") 131 + }
+81 -57
main.go
··· 8 8 "context" 9 9 "fmt" 10 10 "os" 11 - "os/exec" 11 + "path/filepath" 12 12 "runtime/debug" 13 13 "strings" 14 14 ··· 16 16 "github.com/spf13/cobra" 17 17 ) 18 18 19 + type mode int 20 + 21 + const ( 22 + modeUnknown mode = iota 23 + modeCommit 24 + modeTag 25 + modeRoot 26 + ) 27 + 19 28 var ( 20 29 commitType string 21 30 message string ··· 27 36 amend bool 28 37 ) 29 38 39 + func 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 + 30 53 var rootCmd = &cobra.Command{ 31 - Use: "formatted-commit", 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 57 + subject length validation, body wrapping, and trailer formatting. 58 + 59 + This 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 + 63 + Run 'git-format install' to create the symlinks.`, 64 + RunE: func(cmd *cobra.Command, args []string) error { 65 + return cmd.Help() 66 + }, 67 + } 68 + 69 + var commitCmd = &cobra.Command{ 70 + Use: "git-formatted-commit", 32 71 Short: "Create conventionally formatted Git commits", 33 - Long: `formatted-commit helps you create well-formatted Git commits that follow 72 + Long: `git-formatted-commit helps you create well-formatted Git commits that follow 34 73 the Conventional Commits specification with proper subject length validation, 35 74 body wrapping, and trailer formatting.`, 36 75 Example: ` 37 76 # With Assisted-by 38 - formatted-commit -t feat -m "do a thing" -T "Assisted-by: GLM 4.6 via Crush" 77 + git formatted-commit -t feat -m "do a thing" -T "Assisted-by: GLM 4.6 via Crush" 39 78 40 79 # Breaking change with description 41 - formatted-commit -t feat -m "remove deprecated API" \ 80 + git formatted-commit -t feat -m "remove deprecated API" \ 42 81 -B "The old /v1/users endpoint is removed. Use /v2/users instead." 43 82 44 83 # Breaking change with multi-line description using heredoc 45 - formatted-commit -t feat -m "restructure config format" -B "$(cat <<'EOF' 84 + git formatted-commit -t feat -m "restructure config format" -B "$(cat <<'EOF' 46 85 Configuration format changed from JSON to TOML. 47 86 Migrate by running: ./migrate-config.sh 48 87 EOF 49 88 )" -b "Improves readability and supports comments" 50 89 51 90 # Including scope for more precise changes 52 - formatted-commit -t refactor -s "web/git-bug" -m "fancy shmancy" \ 91 + git formatted-commit -t refactor -s "web/git-bug" -m "fancy shmancy" \ 53 92 -b "Had to do a weird thing because..." 54 - 55 - # Check for upgrades 56 - formatted-commit upgrade 57 - 58 - # Then apply 59 - formatted-commit upgrade -a 60 93 `, 61 94 RunE: func(cmd *cobra.Command, args []string) error { 62 95 subject, err := buildAndValidateSubject(commitType, scope, message, breakingChange) ··· 103 136 } 104 137 gitArgs = append(gitArgs, "-F", "-") 105 138 106 - gitCmd := exec.Command("git", gitArgs...) 107 - gitCmd.Stdout = os.Stdout 108 - gitCmd.Stderr = os.Stderr 109 - 110 - stdin, err := gitCmd.StdinPipe() 111 - if err != nil { 112 - return fmt.Errorf("failed to create stdin pipe: %w", err) 113 - } 114 - 115 - if err := gitCmd.Start(); err != nil { 116 - return fmt.Errorf("failed to start git command: %w", err) 117 - } 118 - 119 - if _, err := stdin.Write([]byte(commitMsg.String())); err != nil { 120 - return fmt.Errorf("failed to write to git stdin: %w", err) 121 - } 122 - 123 - if err := stdin.Close(); err != nil { 124 - return fmt.Errorf("failed to close stdin: %w", err) 125 - } 126 - 127 - if err := gitCmd.Wait(); err != nil { 128 - return fmt.Errorf("git commit failed: %w", err) 129 - } 130 - 131 - return nil 139 + return runGitWithStdin(gitArgs, commitMsg.String()) 132 140 }, 133 141 } 134 142 135 143 func init() { 136 - rootCmd.Flags().StringVarP(&commitType, "type", "t", "", "commit type (required)") 137 - rootCmd.Flags().StringVarP(&message, "message", "m", "", "commit message (required)") 138 - rootCmd.Flags().StringArrayVarP(&trailers, "trailer", "T", []string{}, "trailer in 'Sentence-case-key: value' format (optional, repeatable)") 139 - rootCmd.Flags().StringVarP(&body, "body", "b", "", "commit body (optional)") 140 - rootCmd.Flags().StringVarP(&scope, "scope", "s", "", "commit scope (optional)") 141 - rootCmd.Flags().StringVarP(&breakingChange, "breaking", "B", "", "breaking change description (optional)") 142 - rootCmd.Flags().BoolVarP(&add, "add", "a", false, "stage all modified files before committing (optional)") 143 - rootCmd.Flags().BoolVar(&amend, "amend", false, "amend the previous commit (optional)") 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)") 144 152 145 - if err := rootCmd.MarkFlagRequired("type"); err != nil { 153 + if err := commitCmd.MarkFlagRequired("type"); err != nil { 146 154 panic(err) 147 155 } 148 - if err := rootCmd.MarkFlagRequired("message"); err != nil { 156 + if err := commitCmd.MarkFlagRequired("message"); err != nil { 149 157 panic(err) 150 158 } 159 + 160 + rootCmd.AddCommand(installCmd) 151 161 } 152 162 153 163 func buildAndValidateSubject(commitType, scope, message string, breaking string) (string, error) { ··· 169 179 subject.WriteString(message) 170 180 171 181 result := subject.String() 172 - length := len(result) 173 - 174 - if length > 50 { 175 - exceededBy := length - 50 176 - truncated := result[:50] + "…" 177 - return "", fmt.Errorf("subject exceeds 50 character limit by %d:\n%s", exceededBy, truncated) 182 + if err := validateSubjectLength(result); err != nil { 183 + return "", err 178 184 } 179 - 180 185 return result, nil 181 186 } 182 187 ··· 191 196 version = "dev" 192 197 } 193 198 194 - if err := fang.Execute(ctx, rootCmd, 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, 195 219 fang.WithVersion(version), 196 220 fang.WithoutCompletions(), 197 221 ); err != nil {
+81
tag.go
··· 1 + // SPDX-FileCopyrightText: Amolith <amolith@secluded.site> 2 + // 3 + // SPDX-License-Identifier: AGPL-3.0-or-later 4 + 5 + package main 6 + 7 + import ( 8 + "fmt" 9 + "strings" 10 + 11 + "github.com/spf13/cobra" 12 + ) 13 + 14 + var ( 15 + tagMessage string 16 + tagBody string 17 + tagForce bool 18 + ) 19 + 20 + var tagCmd = &cobra.Command{ 21 + Use: "git-formatted-tag NAME", 22 + Short: "Create formatted annotated Git tags", 23 + Long: `git-formatted-tag helps you create well-formatted annotated Git tags with 24 + proper subject length validation and body wrapping.`, 25 + Example: ` 26 + # Simple tag with just a message 27 + git formatted-tag v1.0.0 -m "Initial stable release" 28 + 29 + # Tag with body 30 + git formatted-tag v1.2.0 -m "Add user authentication" -b "$(cat <<'EOF' 31 + ✨ Features 32 + - [auth]: implement JWT-based authentication 33 + - [api]: add login and logout endpoints 34 + 35 + 🐛 Bug Fixes 36 + - [login]: correct password validation error 37 + EOF 38 + )" 39 + 40 + # Force overwrite an existing tag 41 + git formatted-tag v1.0.0 -m "Re-release with hotfix" -f 42 + `, 43 + Args: cobra.ExactArgs(1), 44 + RunE: func(cmd *cobra.Command, args []string) error { 45 + tagName := args[0] 46 + 47 + if tagMessage == "" { 48 + return fmt.Errorf("formatted-tag requires at least a subject (-m)\nFor lightweight tags, use: git tag %s", tagName) 49 + } 50 + 51 + if err := validateSubjectLength(tagMessage); err != nil { 52 + return err 53 + } 54 + 55 + var tagContent strings.Builder 56 + tagContent.WriteString(tagMessage) 57 + 58 + if tagBody != "" { 59 + formattedBody, err := formatBody(tagBody) 60 + if err != nil { 61 + return fmt.Errorf("failed to format body: %w", err) 62 + } 63 + tagContent.WriteString("\n\n") 64 + tagContent.WriteString(formattedBody) 65 + } 66 + 67 + gitArgs := []string{"tag", "-a", tagName} 68 + if tagForce { 69 + gitArgs = append(gitArgs, "-f") 70 + } 71 + gitArgs = append(gitArgs, "-F", "-") 72 + 73 + return runGitWithStdin(gitArgs, tagContent.String()) 74 + }, 75 + } 76 + 77 + func init() { 78 + tagCmd.Flags().StringVarP(&tagMessage, "message", "m", "", "tag subject (required, max 50 characters)") 79 + tagCmd.Flags().StringVarP(&tagBody, "body", "b", "", "tag body (optional)") 80 + tagCmd.Flags().BoolVarP(&tagForce, "force", "f", false, "replace existing tag") 81 + }
+1 -1
upgrade.go
··· 119 119 } 120 120 121 121 if !apply { 122 - fmt.Printf("\nRun %s to apply the upgrade\n", cmdStyle.Render("formatted-commit upgrade -a")) 122 + fmt.Printf("\nRun %s to apply the upgrade\n", cmdStyle.Render("git-format upgrade -a")) 123 123 return nil 124 124 } 125 125