feat(config): migrate deprecated co_authored_by

Amolith created

The old co_authored_by boolean is now deprecated in favor of
trailer_style enum. When only the old setting is present, it silently
migrates (true → co-authored-by, false → none). When trailer_style
is set, co_authored_by is ignored. The deprecated field remains in the
schema marked with deprecated: true so editors can warn users.

Assisted-by: Claude Sonnet 4.5 via Crush

Change summary

internal/config/attribution_migration_test.go | 95 +++++++++++++++++++++
internal/config/config.go                     | 11 ++
internal/config/load.go                       |  7 +
schema.json                                   |  5 +
4 files changed, 118 insertions(+)

Detailed changes

internal/config/attribution_migration_test.go 🔗

@@ -0,0 +1,95 @@
+package config
+
+import (
+	"io"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestAttributionMigration(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name             string
+		configJSON       string
+		expectedTrailer  TrailerStyle
+		expectedGenerate bool
+	}{
+		{
+			name: "old setting co_authored_by=true migrates to co-authored-by",
+			configJSON: `{
+				"options": {
+					"attribution": {
+						"co_authored_by": true,
+						"generated_with": false
+					}
+				}
+			}`,
+			expectedTrailer:  TrailerStyleCoAuthoredBy,
+			expectedGenerate: false,
+		},
+		{
+			name: "old setting co_authored_by=false migrates to none",
+			configJSON: `{
+				"options": {
+					"attribution": {
+						"co_authored_by": false,
+						"generated_with": true
+					}
+				}
+			}`,
+			expectedTrailer:  TrailerStyleNone,
+			expectedGenerate: true,
+		},
+		{
+			name: "new setting takes precedence over old setting",
+			configJSON: `{
+				"options": {
+					"attribution": {
+						"trailer_style": "assisted-by",
+						"co_authored_by": true,
+						"generated_with": false
+					}
+				}
+			}`,
+			expectedTrailer:  TrailerStyleAssistedBy,
+			expectedGenerate: false,
+		},
+		{
+			name: "default when neither setting present",
+			configJSON: `{
+				"options": {
+					"attribution": {
+						"generated_with": true
+					}
+				}
+			}`,
+			expectedTrailer:  TrailerStyleCoAuthoredBy,
+			expectedGenerate: true,
+		},
+		{
+			name: "default when attribution is null",
+			configJSON: `{
+				"options": {}
+			}`,
+			expectedTrailer:  TrailerStyleCoAuthoredBy,
+			expectedGenerate: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			cfg, err := loadFromReaders([]io.Reader{strings.NewReader(tt.configJSON)})
+			require.NoError(t, err)
+
+			cfg.setDefaults(t.TempDir(), "")
+
+			require.Equal(t, tt.expectedTrailer, cfg.Options.Attribution.TrailerStyle)
+			require.Equal(t, tt.expectedGenerate, cfg.Options.Attribution.GeneratedWith)
+		})
+	}
+}

internal/config/config.go 🔗

@@ -14,6 +14,7 @@ import (
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/env"
+	"github.com/invopop/jsonschema"
 	"github.com/tidwall/sjson"
 )
 
@@ -176,9 +177,19 @@ const (
 
 type Attribution struct {
 	TrailerStyle  TrailerStyle `json:"trailer_style,omitempty" jsonschema:"description=Style of attribution trailer to add to commits,enum=none,enum=co-authored-by,enum=assisted-by,default=co-authored-by"`
+	CoAuthoredBy  *bool        `json:"co_authored_by,omitempty" jsonschema:"description=Deprecated: use trailer_style instead"`
 	GeneratedWith bool         `json:"generated_with,omitempty" jsonschema:"description=Add Generated with Crush line to commit messages and issues and PRs,default=true"`
 }
 
+// JSONSchemaExtend marks the co_authored_by field as deprecated in the schema.
+func (Attribution) JSONSchemaExtend(schema *jsonschema.Schema) {
+	if schema.Properties != nil {
+		if prop, ok := schema.Properties.Get("co_authored_by"); ok {
+			prop.Deprecated = true
+		}
+	}
+}
+
 type Options struct {
 	ContextPaths              []string     `json:"context_paths,omitempty" jsonschema:"description=Paths to files containing context information for the AI,example=.cursorrules,example=CRUSH.md"`
 	TUI                       *TUIOptions  `json:"tui,omitempty" jsonschema:"description=Terminal user interface options"`

internal/config/load.go 🔗

@@ -356,6 +356,13 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
 			TrailerStyle:  TrailerStyleCoAuthoredBy,
 			GeneratedWith: true,
 		}
+	} else if c.Options.Attribution.TrailerStyle == "" {
+		// Migrate deprecated co_authored_by or apply default
+		if c.Options.Attribution.CoAuthoredBy != nil && !*c.Options.Attribution.CoAuthoredBy {
+			c.Options.Attribution.TrailerStyle = TrailerStyleNone
+		} else {
+			c.Options.Attribution.TrailerStyle = TrailerStyleCoAuthoredBy
+		}
 	}
 }
 

schema.json 🔗

@@ -15,6 +15,11 @@
           "description": "Style of attribution trailer to add to commits",
           "default": "co-authored-by"
         },
+        "co_authored_by": {
+          "type": "boolean",
+          "description": "Deprecated: use trailer_style instead",
+          "deprecated": true
+        },
         "generated_with": {
           "type": "boolean",
           "description": "Add Generated with Crush line to commit messages and issues and PRs",