fix(config): make migration idempotent
- commit
- 4d6831449de1ca793129e77967f6a503a16170c6
- author
- brice · · 2026-06-18 04:15 UTC
- parent
- 6a62fb1
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +68 -3
M internal/infrastructure/config/migrate.go +20 -3
| @@ -195,7 +195,7 @@ func (m *Migrator) DetectChanges() ([]port.KeyChange, error) { | |||
| 195 | 195 | func (m *Migrator) findDeprecatedKeys(userKeys map[string]any, defaultKeys map[string]bool) []string { | |
| 196 | 196 | var deprecated []string | |
| 197 | 197 | for userKey := range userKeys { | |
| 198 | - | if userKey == databasePathKey { | |
| 198 | + | if m.isMigrationIgnoredDeprecatedKey(userKey) { | |
| 199 | 199 | continue | |
| 200 | 200 | } | |
| 201 | 201 | if !m.keyOrRelatedExistsInDefaults(userKey, defaultKeys) { | |
| @@ -813,9 +813,12 @@ func (m *Migrator) flattenMap(data map[string]any, prefix string, keys map[strin | |||
| 813 | 813 | // isUserDataSection checks if a key path represents user-defined data | |
| 814 | 814 | // that should not be compared key-by-key (e.g., search shortcuts, action bindings). | |
| 815 | 815 | func (*Migrator) isUserDataSection(keyPath string) bool { | |
| 816 | - | // User data sections - treat entire section as one key | |
| 816 | + | // User data sections - treat entire section as one key. | |
| 817 | + | // Some of these sections legitimately default to an empty map/table; keeping | |
| 818 | + | // them as leaf keys makes detection agree with TOML writing. | |
| 817 | 819 | switch keyPath { | |
| 818 | - | case "search_shortcuts": | |
| 820 | + | case "search_shortcuts", | |
| 821 | + | "workspace.floating_pane.profiles": | |
| 819 | 822 | return true | |
| 820 | 823 | } | |
| 821 | 824 | ||
| @@ -917,6 +920,20 @@ func appendUniqueKeys(base, extra []string) []string { | |||
| 917 | 920 | return base | |
| 918 | 921 | } | |
| 919 | 922 | ||
| 923 | + | func (*Migrator) isMigrationIgnoredDeprecatedKey(key string) bool { | |
| 924 | + | switch key { | |
| 925 | + | case databasePathKey: | |
| 926 | + | return true | |
| 927 | + | case "appearance.external_theme.path": | |
| 928 | + | // Runtime may derive this path when it is omitted, so it is not a Viper | |
| 929 | + | // default. It remains a valid explicit config key and should not be | |
| 930 | + | // removed as deprecated. | |
| 931 | + | return true | |
| 932 | + | default: | |
| 933 | + | return false | |
| 934 | + | } | |
| 935 | + | } | |
| 936 | + | ||
| 920 | 937 | func excludeKeys(keys []string, excluded map[string]bool) []string { | |
| 921 | 938 | if len(excluded) == 0 { | |
| 922 | 939 | return keys | |
| @@ -195,7 +195,7 @@ func (m *Migrator) DetectChanges() ([]port.KeyChange, error) { | |||||
| 195 | func (m *Migrator) findDeprecatedKeys(userKeys map[string]any, defaultKeys map[string]bool) []string { | 195 | func (m *Migrator) findDeprecatedKeys(userKeys map[string]any, defaultKeys map[string]bool) []string { | ||
| 196 | var deprecated []string | 196 | var deprecated []string | ||
| 197 | for userKey := range userKeys { | 197 | for userKey := range userKeys { | ||
| 198 | - | if userKey == databasePathKey { | 198 | + | if m.isMigrationIgnoredDeprecatedKey(userKey) { |
| 199 | continue | 199 | continue | ||
| 200 | } | 200 | } | ||
| 201 | if !m.keyOrRelatedExistsInDefaults(userKey, defaultKeys) { | 201 | if !m.keyOrRelatedExistsInDefaults(userKey, defaultKeys) { | ||
| @@ -813,9 +813,12 @@ func (m *Migrator) flattenMap(data map[string]any, prefix string, keys map[strin | |||||
| 813 | // isUserDataSection checks if a key path represents user-defined data | 813 | // isUserDataSection checks if a key path represents user-defined data | ||
| 814 | // that should not be compared key-by-key (e.g., search shortcuts, action bindings). | 814 | // that should not be compared key-by-key (e.g., search shortcuts, action bindings). | ||
| 815 | func (*Migrator) isUserDataSection(keyPath string) bool { | 815 | func (*Migrator) isUserDataSection(keyPath string) bool { | ||
| 816 | - | // User data sections - treat entire section as one key | 816 | + | // User data sections - treat entire section as one key. |
| 817 | + | // Some of these sections legitimately default to an empty map/table; keeping | |||
| 818 | + | // them as leaf keys makes detection agree with TOML writing. | |||
| 817 | switch keyPath { | 819 | switch keyPath { | ||
| 818 | - | case "search_shortcuts": | 820 | + | case "search_shortcuts", |
| 821 | + | "workspace.floating_pane.profiles": | |||
| 819 | return true | 822 | return true | ||
| 820 | } | 823 | } | ||
| 821 | 824 | ||||
| @@ -917,6 +920,20 @@ func appendUniqueKeys(base, extra []string) []string { | |||||
| 917 | return base | 920 | return base | ||
| 918 | } | 921 | } | ||
| 919 | 922 | ||||
| 923 | + | func (*Migrator) isMigrationIgnoredDeprecatedKey(key string) bool { | |||
| 924 | + | switch key { | |||
| 925 | + | case databasePathKey: | |||
| 926 | + | return true | |||
| 927 | + | case "appearance.external_theme.path": | |||
| 928 | + | // Runtime may derive this path when it is omitted, so it is not a Viper | |||
| 929 | + | // default. It remains a valid explicit config key and should not be | |||
| 930 | + | // removed as deprecated. | |||
| 931 | + | return true | |||
| 932 | + | default: | |||
| 933 | + | return false | |||
| 934 | + | } | |||
| 935 | + | } | |||
| 936 | + | ||||
| 920 | func excludeKeys(keys []string, excluded map[string]bool) []string { | 937 | func excludeKeys(keys []string, excluded map[string]bool) []string { | ||
| 921 | if len(excluded) == 0 { | 938 | if len(excluded) == 0 { | ||
| 922 | return keys | 939 | return keys | ||
M internal/infrastructure/config/migrate_test.go +48 -0
| @@ -974,6 +974,54 @@ func TestMigrator_DeleteNestedKey(t *testing.T) { | |||
| 974 | 974 | }) | |
| 975 | 975 | } | |
| 976 | 976 | ||
| 977 | + | func TestMigrator_Migrate_IsIdempotentForExternalThemePathAndEmptyProfiles(t *testing.T) { | |
| 978 | + | home := t.TempDir() | |
| 979 | + | t.Setenv("HOME", home) | |
| 980 | + | t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) | |
| 981 | + | t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share")) | |
| 982 | + | t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state")) | |
| 983 | + | t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) | |
| 984 | + | ||
| 985 | + | configFile, err := GetConfigFile() | |
| 986 | + | require.NoError(t, err) | |
| 987 | + | require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755)) | |
| 988 | + | ||
| 989 | + | content, err := encodeConfigToTOML(DefaultConfig()) | |
| 990 | + | require.NoError(t, err) | |
| 991 | + | require.Contains(t, content, "path = '") | |
| 992 | + | require.Contains(t, content, "[workspace.floating_pane.profiles]") | |
| 993 | + | content = strings.Replace(content, "\n [workspace.floating_pane.profiles]\n", "", 1) | |
| 994 | + | require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644)) | |
| 995 | + | ||
| 996 | + | m := NewMigrator() | |
| 997 | + | firstChanges, err := m.DetectChanges() | |
| 998 | + | require.NoError(t, err) | |
| 999 | + | assertContainsChange(t, firstChanges, port.KeyChangeAdded, "", "workspace.floating_pane.profiles") | |
| 1000 | + | ||
| 1001 | + | appliedKeys, err := m.Migrate() | |
| 1002 | + | require.NoError(t, err) | |
| 1003 | + | require.NotEmpty(t, appliedKeys, "first migration should apply the missing profiles default") | |
| 1004 | + | ||
| 1005 | + | secondChanges, err := m.DetectChanges() | |
| 1006 | + | require.NoError(t, err) | |
| 1007 | + | assert.Empty(t, secondChanges, "second migration detection should report no changes after a successful migration") | |
| 1008 | + | ||
| 1009 | + | secondAppliedKeys, err := m.Migrate() | |
| 1010 | + | require.NoError(t, err) | |
| 1011 | + | assert.Empty(t, secondAppliedKeys, "second migration should be a no-op") | |
| 1012 | + | } | |
| 1013 | + | ||
| 1014 | + | func assertContainsChange(t *testing.T, changes []port.KeyChange, changeType port.KeyChangeType, oldKey, newKey string) { | |
| 1015 | + | t.Helper() | |
| 1016 | + | ||
| 1017 | + | for _, change := range changes { | |
| 1018 | + | if change.Type == changeType && change.OldKey == oldKey && change.NewKey == newKey { | |
| 1019 | + | return | |
| 1020 | + | } | |
| 1021 | + | } | |
| 1022 | + | assert.Failf(t, "missing expected change", "type=%v old=%q new=%q changes=%v", changeType, oldKey, newKey, changes) | |
| 1023 | + | } | |
| 1024 | + | ||
| 977 | 1025 | // --- Migrate-path popup → browsing_contexts transform tests --- | |
| 978 | 1026 | ||
| 979 | 1027 | func TestMigrator_GetUserConfigKeysWithValues_TransformsLegacyPopups(t *testing.T) { | |
| @@ -974,6 +974,54 @@ func TestMigrator_DeleteNestedKey(t *testing.T) { | |||||
| 974 | }) | 974 | }) | ||
| 975 | } | 975 | } | ||
| 976 | 976 | ||||
| 977 | + | func TestMigrator_Migrate_IsIdempotentForExternalThemePathAndEmptyProfiles(t *testing.T) { | |||
| 978 | + | home := t.TempDir() | |||
| 979 | + | t.Setenv("HOME", home) | |||
| 980 | + | t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) | |||
| 981 | + | t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share")) | |||
| 982 | + | t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state")) | |||
| 983 | + | t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) | |||
| 984 | + | ||||
| 985 | + | configFile, err := GetConfigFile() | |||
| 986 | + | require.NoError(t, err) | |||
| 987 | + | require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755)) | |||
| 988 | + | ||||
| 989 | + | content, err := encodeConfigToTOML(DefaultConfig()) | |||
| 990 | + | require.NoError(t, err) | |||
| 991 | + | require.Contains(t, content, "path = '") | |||
| 992 | + | require.Contains(t, content, "[workspace.floating_pane.profiles]") | |||
| 993 | + | content = strings.Replace(content, "\n [workspace.floating_pane.profiles]\n", "", 1) | |||
| 994 | + | require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644)) | |||
| 995 | + | ||||
| 996 | + | m := NewMigrator() | |||
| 997 | + | firstChanges, err := m.DetectChanges() | |||
| 998 | + | require.NoError(t, err) | |||
| 999 | + | assertContainsChange(t, firstChanges, port.KeyChangeAdded, "", "workspace.floating_pane.profiles") | |||
| 1000 | + | ||||
| 1001 | + | appliedKeys, err := m.Migrate() | |||
| 1002 | + | require.NoError(t, err) | |||
| 1003 | + | require.NotEmpty(t, appliedKeys, "first migration should apply the missing profiles default") | |||
| 1004 | + | ||||
| 1005 | + | secondChanges, err := m.DetectChanges() | |||
| 1006 | + | require.NoError(t, err) | |||
| 1007 | + | assert.Empty(t, secondChanges, "second migration detection should report no changes after a successful migration") | |||
| 1008 | + | ||||
| 1009 | + | secondAppliedKeys, err := m.Migrate() | |||
| 1010 | + | require.NoError(t, err) | |||
| 1011 | + | assert.Empty(t, secondAppliedKeys, "second migration should be a no-op") | |||
| 1012 | + | } | |||
| 1013 | + | ||||
| 1014 | + | func assertContainsChange(t *testing.T, changes []port.KeyChange, changeType port.KeyChangeType, oldKey, newKey string) { | |||
| 1015 | + | t.Helper() | |||
| 1016 | + | ||||
| 1017 | + | for _, change := range changes { | |||
| 1018 | + | if change.Type == changeType && change.OldKey == oldKey && change.NewKey == newKey { | |||
| 1019 | + | return | |||
| 1020 | + | } | |||
| 1021 | + | } | |||
| 1022 | + | assert.Failf(t, "missing expected change", "type=%v old=%q new=%q changes=%v", changeType, oldKey, newKey, changes) | |||
| 1023 | + | } | |||
| 1024 | + | ||||
| 977 | // --- Migrate-path popup → browsing_contexts transform tests --- | 1025 | // --- Migrate-path popup → browsing_contexts transform tests --- | ||
| 978 | 1026 | ||||
| 979 | func TestMigrator_GetUserConfigKeysWithValues_TransformsLegacyPopups(t *testing.T) { | 1027 | func TestMigrator_GetUserConfigKeysWithValues_TransformsLegacyPopups(t *testing.T) { | ||