diff --git a/AGENTS.md b/AGENTS.md index 4a109b2a69e0963e92b089a3e4fdb672f185e793..c2a9bb352396d911c82bd3c2785f0c6bc2b6dbda 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ The body (`-b` flag) processing pipeline: - **Blank lines**: Preserved as-is 3. **Word wrapping algorithm**: Greedy wrapping splits on word boundaries, never mid-word 4. **Hanging indent logic**: For bullets/numbered lists, first line gets the marker, continuation lines get spaces equal to marker width -5. **Spacing**: Body separated from subject by one blank line, from trailers by one blank line +5. **Spacing**: Body separated from subject by one blank line, from breaking change footer (if present) by one blank line, from trailers by one blank line Example wrapped bullet: @@ -106,12 +106,37 @@ Co-authored-by: This is a very long value, with spaces and - `-t` / `--type`: Commit type (required) - e.g., `feat`, `fix`, `refactor` - `-m` / `--message`: Commit message (required) - the description after the colon - `-s` / `--scope`: Commit scope (optional) - goes in parentheses after type -- `-B` / `--breaking`: Boolean flag for breaking changes (adds `!` to subject) +- `-B` / `--breaking`: String flag for breaking change description (optional) - adds `!` to subject AND creates `BREAKING CHANGE:` footer - `-b` / `--body`: String flag for commit body text (can use heredoc for multiline) - `-T` / `--trailer`: Repeatable flag accepting full trailer strings in `Key: value` format (not separate key/value args) +- `-a` / `--amend`: Boolean flag to amend the previous commit instead of creating a new one -Trailer format detail: Each `-T` flag takes a complete trailer string like `-T "Co-authored-by: Name "`, NOT separate key and value arguments. +Breaking change detail: The `-B` flag value becomes the description in a `BREAKING CHANGE:` footer (with space, per Conventional Commits spec). This footer is distinct from git trailers and allows spaces in the key. It's inserted between the body and trailers. + +Trailer format detail: Each `-T` flag takes a complete trailer string like `-T "Assisted-by: Claude Sonnet 4.5 via Crush"`, NOT separate key and value arguments. ### Final Output -The formatted commit message must be piped to `git commit -F -` to read from stdin. +The formatted commit message structure: + +1. Subject: `type(scope)!: message` (or `type!: message` if no scope) +2. Blank line +3. Body (if `-b` provided) +4. Blank line (if body or breaking change present) +5. `BREAKING CHANGE: description` footer (if `-B` provided) +6. Blank line (if trailers present) +7. Trailers (if `-T` provided) + +Example with all components: +``` +feat!: restructure config + +Improves readability and supports comments + +BREAKING CHANGE: Configuration format changed from JSON to TOML. +Migrate by running: ./migrate-config.sh + +Assisted-by: Claude Sonnet 4.5 via Crush +``` + +The formatted commit message must be piped to `git commit -F -` to read from stdin. When the `-a`/`--amend` flag is used, it pipes to `git commit --amend -F -` instead. diff --git a/main.go b/main.go index db3880e31b59a8c3dbf906c6fc47bc6502dfd68c..c89cd1d62e715d1999dff3764c78d1d8505e8bff 100644 --- a/main.go +++ b/main.go @@ -22,7 +22,8 @@ var ( trailers []string body string scope string - breakingChange bool + breakingChange string + amend bool ) var rootCmd = &cobra.Command{ @@ -32,18 +33,19 @@ var rootCmd = &cobra.Command{ the Conventional Commits specification with proper subject length validation, body wrapping, and trailer formatting.`, Example: ` -# With co-author -formatted-commit -t feat -m "do a thing" -T "Crush " +# With assisted-by trailer +formatted-commit -t feat -m "do a thing" -T "Assisted-by: Veldt via Crush" -# Breaking change with longer body -formatted-commit -t feat -m "do a thing that borks a thing" -B "$(cat <<'EOF' -Multi-line -- Body -- Here +# Breaking change with description +formatted-commit -t feat -m "remove deprecated API" \ + -B "The old /v1/users endpoint is removed. Use /v2/users instead." -This is what borked because of new shiny, this is how migrate +# Breaking change with multi-line description using heredoc +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" \ @@ -55,10 +57,6 @@ formatted-commit -t refactor -s "web/git-bug" -m "fancy shmancy" \ return err } - if breakingChange && !hasBreakingChangeFooter(body) { - return fmt.Errorf("breaking change flag (-B) requires a BREAKING CHANGE: or CHANGES: footer at the end of the body. It instructs users how to resolve the breaking changes resulting from this commit") - } - var commitMsg strings.Builder commitMsg.WriteString(subject) @@ -71,6 +69,16 @@ formatted-commit -t refactor -s "web/git-bug" -m "fancy shmancy" \ commitMsg.WriteString(formattedBody) } + if breakingChange != "" { + formattedBreaking, err := formatBody(breakingChange) + if err != nil { + return fmt.Errorf("failed to format breaking change: %w", err) + } + commitMsg.WriteString("\n\n") + commitMsg.WriteString("BREAKING CHANGE: ") + commitMsg.WriteString(formattedBreaking) + } + if len(trailers) > 0 { trailersBlock, err := buildTrailersBlock(trailers) if err != nil { @@ -80,7 +88,13 @@ formatted-commit -t refactor -s "web/git-bug" -m "fancy shmancy" \ commitMsg.WriteString(trailersBlock) } - gitCmd := exec.Command("git", "commit", "-F", "-") + gitArgs := []string{"commit"} + if amend { + gitArgs = append(gitArgs, "--amend") + } + gitArgs = append(gitArgs, "-F", "-") + + gitCmd := exec.Command("git", gitArgs...) gitCmd.Stdout = os.Stdout gitCmd.Stderr = os.Stderr @@ -115,7 +129,8 @@ func init() { 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().BoolVarP(&breakingChange, "breaking", "B", false, "mark as breaking change (optional)") + rootCmd.Flags().StringVarP(&breakingChange, "breaking", "B", "", "breaking change description (optional, adds BREAKING CHANGE footer)") + rootCmd.Flags().BoolVarP(&amend, "amend", "a", false, "amend the previous commit (optional)") if err := rootCmd.MarkFlagRequired("type"); err != nil { panic(err) @@ -125,7 +140,7 @@ func init() { } } -func buildAndValidateSubject(commitType, scope, message string, breaking bool) (string, error) { +func buildAndValidateSubject(commitType, scope, message string, breaking string) (string, error) { var subject strings.Builder subject.WriteString(commitType) @@ -136,7 +151,7 @@ func buildAndValidateSubject(commitType, scope, message string, breaking bool) ( subject.WriteString(")") } - if breaking { + if breaking != "" { subject.WriteString("!") } @@ -155,17 +170,6 @@ func buildAndValidateSubject(commitType, scope, message string, breaking bool) ( return result, nil } -func hasBreakingChangeFooter(body string) bool { - lines := strings.Split(body, "\n") - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "BREAKING CHANGE:") || strings.HasPrefix(trimmed, "BREAKING CHANGES:") { - return true - } - } - return false -} - func main() { ctx := context.Background()