feat(cli): require description for -B flag

Amolith created

The -B flag now accepts a string argument containing the breaking change
description instead of being a boolean flag. This description is
formatted and inserted as a BREAKING CHANGE: footer between the body and
git trailers, following the Conventional Commits specification.

The flag still adds ! to the subject line. When used with heredoc
syntax, multi-line breaking change descriptions are supported.

Also re-added the -a/--amend flag that was present in main but missing
from this feature branch.

Implements: bug-e75a648
Assisted-by: Claude Sonnet 4.5 via Crush

Change summary

AGENTS.md | 33 ++++++++++++++++++++++++++---
main.go   | 62 ++++++++++++++++++++++++++++++--------------------------
2 files changed, 62 insertions(+), 33 deletions(-)

Detailed changes

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 <email>"`, 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.

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 <crush@charm.land>"
+# 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()