detached 4d68314

fix(config): make migration idempotent

commit
4d6831449de1ca793129e77967f6a503a16170c6
author
brice · · 2026-06-18 04:15 UTC
parent
6a62fb1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
195195 func (m *Migrator) findDeprecatedKeys(userKeys map[string]any, defaultKeys map[string]bool) []string {
196196 var deprecated []string
197197 for userKey := range userKeys {
198- if userKey == databasePathKey {
198+ if m.isMigrationIgnoredDeprecatedKey(userKey) {
199199 continue
200200 }
201201 if !m.keyOrRelatedExistsInDefaults(userKey, defaultKeys) {
@@ -813,9 +813,12 @@ func (m *Migrator) flattenMap(data map[string]any, prefix string, keys map[strin
813813 // isUserDataSection checks if a key path represents user-defined data
814814 // that should not be compared key-by-key (e.g., search shortcuts, action bindings).
815815 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.
817819 switch keyPath {
818- case "search_shortcuts":
820+ case "search_shortcuts",
821+ "workspace.floating_pane.profiles":
819822 return true
820823 }
821824
@@ -917,6 +920,20 @@ func appendUniqueKeys(base, extra []string) []string {
917920 return base
918921 }
919922
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+
920937 func excludeKeys(keys []string, excluded map[string]bool) []string {
921938 if len(excluded) == 0 {
922939 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 []string196 var deprecated []string
197 for userKey := range userKeys {197 for userKey := range userKeys {
198- if userKey == databasePathKey {198+ if m.isMigrationIgnoredDeprecatedKey(userKey) {
199 continue199 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 data813 // 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 key816+ // 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 true822 return true
820 }823 }
821 824
@@ -917,6 +920,20 @@ func appendUniqueKeys(base, extra []string) []string {
917 return base920 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 keys939 return keys
M internal/infrastructure/config/migrate_test.go +48 -0
@@ -974,6 +974,54 @@ func TestMigrator_DeleteNestedKey(t *testing.T) {
974974 })
975975 }
976976
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+
9771025 // --- Migrate-path popup → browsing_contexts transform tests ---
9781026
9791027 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) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close