Skip to content

Commit 7a24c56

Browse files
authored
Print migration changes to the console when migrating config file (#4548)
- **PR Description** This might be useful to see in general (users will normally only see it after they quit lazygit again, but still). But it is especially useful when writing back the config file fails; some users have their config file in a read-only location, so we had reports of lazygit no longer starting up when migration was necessary. #4210 was supposed to improve this a bit, but it didn't tell users what changes need to be made to the config file. Now we tell them, and users can then make these changes manually if they want. We do this only at startup, when the GUI hasn't started yet. This is probably good enough, because it is much less likely that writing back a migrated repo-local config fails because it is not writeable. Example output: ``` The user config file /Users/stk/Library/Application Support/lazygit/config.yml must be migrated. Attempting to do this automatically. The following changes were made: - Renamed 'gui.windowSize' to 'screenMode' - Changed 'null' to '<disabled>' for keybinding 'keybinding.universal.confirmInEditor' - Changed 'stream: true' to 'output: log' in custom command Config file saved successfully to /Users/stk/Library/Application Support/lazygit/config.yml ``` The branch also contains a lot of code cleanups. - **Please check if the PR fulfills these requirements** * [x] Cheatsheets are up-to-date (run `go generate ./...`) * [x] Code has been formatted (see [here](https://github.com/jesseduffield/lazygit/blob/master/CONTRIBUTING.md#code-formatting)) * [x] Tests have been added/updated (see [here](https://github.com/jesseduffield/lazygit/blob/master/pkg/integration/README.md) for the integration test guide) * [ ] Text is internationalised (see [here](https://github.com/jesseduffield/lazygit/blob/master/CONTRIBUTING.md#internationalisation)) * [ ] If a new UserConfig entry was added, make sure it can be hot-reloaded (see [here](https://github.com/jesseduffield/lazygit/blob/master/docs/dev/Codebase_Guide.md#using-userconfig)) * [ ] Docs have been updated if necessary * [x] You've read through your own file changes for silly mistakes etc
2 parents 1fbfefa + cabcd54 commit 7a24c56

File tree

10 files changed

+518
-182
lines changed

10 files changed

+518
-182
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ require (
1313
github.com/go-errors/errors v1.5.1
1414
github.com/gookit/color v1.4.2
1515
github.com/integrii/flaggy v1.4.0
16-
github.com/jesseduffield/generics v0.0.0-20250406224309-4f541cb84918
16+
github.com/jesseduffield/generics v0.0.0-20250517122708-b0b4a53a6f5c
1717
github.com/jesseduffield/go-git/v5 v5.14.1-0.20250407170251-e1a013310ccd
1818
github.com/jesseduffield/gocui v0.3.1-0.20250421160159-82c9aaeba2b9
1919
github.com/jesseduffield/kill v0.0.0-20250101124109-e216ddbe133a

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,8 +190,8 @@ github.com/invopop/jsonschema v0.10.0 h1:c1ktzNLBun3LyQQhyty5WE3lulbOdIIyOVlkmDL
190190
github.com/invopop/jsonschema v0.10.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
191191
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
192192
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
193-
github.com/jesseduffield/generics v0.0.0-20250406224309-4f541cb84918 h1:meoUDZGF6jZAbhW5IBwj92mTqGmrOn+Cuu0jM7/aUcs=
194-
github.com/jesseduffield/generics v0.0.0-20250406224309-4f541cb84918/go.mod h1:+LLj9/WUPAP8LqCchs7P+7X0R98HiFujVFANdNaxhGk=
193+
github.com/jesseduffield/generics v0.0.0-20250517122708-b0b4a53a6f5c h1:tC2PaiisXAC5sOjDPfMArSnbswDObtCssx+xn28edX4=
194+
github.com/jesseduffield/generics v0.0.0-20250517122708-b0b4a53a6f5c/go.mod h1:F2fEBk0ddf6ixrBrJjY7phfQ3hL9rXG0uSjvwYe50bE=
195195
github.com/jesseduffield/go-git/v5 v5.14.1-0.20250407170251-e1a013310ccd h1:ViKj6qth8FgcIWizn9KiACWwPemWSymx62OPN0tHT+Q=
196196
github.com/jesseduffield/go-git/v5 v5.14.1-0.20250407170251-e1a013310ccd/go.mod h1:lRhCiBr6XjQrvcQVa+UYsy/99d3wMXn/a0nSQlhnhlA=
197197
github.com/jesseduffield/gocui v0.3.1-0.20250421160159-82c9aaeba2b9 h1:k23sCKHCNpAvwJP8Yr16CBUItuarmUHBGH7FaAm2glc=

pkg/config/app_config.go

Lines changed: 96 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"time"
1212

1313
"github.com/adrg/xdg"
14+
"github.com/jesseduffield/generics/orderedset"
1415
"github.com/jesseduffield/lazygit/pkg/utils"
1516
"github.com/jesseduffield/lazygit/pkg/utils/yaml_utils"
1617
"github.com/samber/lo"
@@ -96,7 +97,7 @@ func NewAppConfig(
9697
configFiles = []*ConfigFile{configFile}
9798
}
9899

99-
userConfig, err := loadUserConfigWithDefaults(configFiles)
100+
userConfig, err := loadUserConfigWithDefaults(configFiles, false)
100101
if err != nil {
101102
return nil, err
102103
}
@@ -145,11 +146,11 @@ func findOrCreateConfigDir() (string, error) {
145146
return folder, os.MkdirAll(folder, 0o755)
146147
}
147148

148-
func loadUserConfigWithDefaults(configFiles []*ConfigFile) (*UserConfig, error) {
149-
return loadUserConfig(configFiles, GetDefaultConfig())
149+
func loadUserConfigWithDefaults(configFiles []*ConfigFile, isGuiInitialized bool) (*UserConfig, error) {
150+
return loadUserConfig(configFiles, GetDefaultConfig(), isGuiInitialized)
150151
}
151152

152-
func loadUserConfig(configFiles []*ConfigFile, base *UserConfig) (*UserConfig, error) {
153+
func loadUserConfig(configFiles []*ConfigFile, base *UserConfig, isGuiInitialized bool) (*UserConfig, error) {
153154
for _, configFile := range configFiles {
154155
path := configFile.Path
155156
statInfo, err := os.Stat(path)
@@ -194,7 +195,7 @@ func loadUserConfig(configFiles []*ConfigFile, base *UserConfig) (*UserConfig, e
194195
return nil, err
195196
}
196197

197-
content, err = migrateUserConfig(path, content)
198+
content, err = migrateUserConfig(path, content, isGuiInitialized)
198199
if err != nil {
199200
return nil, err
200201
}
@@ -215,41 +216,64 @@ func loadUserConfig(configFiles []*ConfigFile, base *UserConfig) (*UserConfig, e
215216
return base, nil
216217
}
217218

219+
type ChangesSet = orderedset.OrderedSet[string]
220+
221+
func NewChangesSet() *ChangesSet {
222+
return orderedset.New[string]()
223+
}
224+
218225
// Do any backward-compatibility migrations of things that have changed in the
219226
// config over time; examples are renaming a key to a better name, moving a key
220227
// from one container to another, or changing the type of a key (e.g. from bool
221228
// to an enum).
222-
func migrateUserConfig(path string, content []byte) ([]byte, error) {
223-
changedContent, err := computeMigratedConfig(path, content)
229+
func migrateUserConfig(path string, content []byte, isGuiInitialized bool) ([]byte, error) {
230+
changes := NewChangesSet()
231+
232+
changedContent, didChange, err := computeMigratedConfig(path, content, changes)
224233
if err != nil {
225234
return nil, err
226235
}
227236

228-
// Write config back if changed
229-
if string(changedContent) != string(content) {
230-
fmt.Println("Provided user config is deprecated but auto-fixable. Attempting to write fixed version back to file...")
231-
if err := os.WriteFile(path, changedContent, 0o644); err != nil {
232-
return nil, fmt.Errorf("While attempting to write back fixed user config to %s, an error occurred: %s", path, err)
233-
}
234-
fmt.Printf("Success. New config written to %s\n", path)
235-
return changedContent, nil
237+
// Nothing to do if config didn't change
238+
if !didChange {
239+
return content, nil
236240
}
237241

238-
return content, nil
242+
changesText := "The following changes were made:\n\n"
243+
changesText += strings.Join(lo.Map(changes.ToSliceFromOldest(), func(change string, _ int) string {
244+
return fmt.Sprintf("- %s\n", change)
245+
}), "")
246+
247+
// Write config back
248+
if !isGuiInitialized {
249+
fmt.Printf("The user config file %s must be migrated. Attempting to do this automatically.\n", path)
250+
fmt.Println(changesText)
251+
}
252+
if err := os.WriteFile(path, changedContent, 0o644); err != nil {
253+
errorMsg := fmt.Sprintf("While attempting to write back migrated user config to %s, an error occurred: %s", path, err)
254+
if isGuiInitialized {
255+
errorMsg += "\n\n" + changesText
256+
}
257+
return nil, errors.New(errorMsg)
258+
}
259+
if !isGuiInitialized {
260+
fmt.Printf("Config file saved successfully to %s\n", path)
261+
}
262+
return changedContent, nil
239263
}
240264

241265
// A pure function helper for testing purposes
242-
func computeMigratedConfig(path string, content []byte) ([]byte, error) {
266+
func computeMigratedConfig(path string, content []byte, changes *ChangesSet) ([]byte, bool, error) {
243267
var err error
244268
var rootNode yaml.Node
245269
err = yaml.Unmarshal(content, &rootNode)
246270
if err != nil {
247-
return nil, fmt.Errorf("failed to parse YAML: %w", err)
271+
return nil, false, fmt.Errorf("failed to parse YAML: %w", err)
248272
}
249273
var originalCopy yaml.Node
250274
err = yaml.Unmarshal(content, &originalCopy)
251275
if err != nil {
252-
return nil, fmt.Errorf("failed to parse YAML, but only the second time!?!? How did that happen: %w", err)
276+
return nil, false, fmt.Errorf("failed to parse YAML, but only the second time!?!? How did that happen: %w", err)
253277
}
254278

255279
pathsToReplace := []struct {
@@ -262,60 +286,64 @@ func computeMigratedConfig(path string, content []byte) ([]byte, error) {
262286
}
263287

264288
for _, pathToReplace := range pathsToReplace {
265-
err := yaml_utils.RenameYamlKey(&rootNode, pathToReplace.oldPath, pathToReplace.newName)
289+
err, didReplace := yaml_utils.RenameYamlKey(&rootNode, pathToReplace.oldPath, pathToReplace.newName)
266290
if err != nil {
267-
return nil, fmt.Errorf("Couldn't migrate config file at `%s` for key %s: %s", path, strings.Join(pathToReplace.oldPath, "."), err)
291+
return nil, false, fmt.Errorf("Couldn't migrate config file at `%s` for key %s: %s", path, strings.Join(pathToReplace.oldPath, "."), err)
292+
}
293+
if didReplace {
294+
changes.Add(fmt.Sprintf("Renamed '%s' to '%s'", strings.Join(pathToReplace.oldPath, "."), pathToReplace.newName))
268295
}
269296
}
270297

271-
err = changeNullKeybindingsToDisabled(&rootNode)
298+
err = changeNullKeybindingsToDisabled(&rootNode, changes)
272299
if err != nil {
273-
return nil, fmt.Errorf("Couldn't migrate config file at `%s`: %s", path, err)
300+
return nil, false, fmt.Errorf("Couldn't migrate config file at `%s`: %s", path, err)
274301
}
275302

276-
err = changeElementToSequence(&rootNode, []string{"git", "commitPrefix"})
303+
err = changeElementToSequence(&rootNode, []string{"git", "commitPrefix"}, changes)
277304
if err != nil {
278-
return nil, fmt.Errorf("Couldn't migrate config file at `%s`: %s", path, err)
305+
return nil, false, fmt.Errorf("Couldn't migrate config file at `%s`: %s", path, err)
279306
}
280307

281-
err = changeCommitPrefixesMap(&rootNode)
308+
err = changeCommitPrefixesMap(&rootNode, changes)
282309
if err != nil {
283-
return nil, fmt.Errorf("Couldn't migrate config file at `%s`: %s", path, err)
310+
return nil, false, fmt.Errorf("Couldn't migrate config file at `%s`: %s", path, err)
284311
}
285312

286-
err = changeCustomCommandStreamAndOutputToOutputEnum(&rootNode)
313+
err = changeCustomCommandStreamAndOutputToOutputEnum(&rootNode, changes)
287314
if err != nil {
288-
return nil, fmt.Errorf("Couldn't migrate config file at `%s`: %s", path, err)
315+
return nil, false, fmt.Errorf("Couldn't migrate config file at `%s`: %s", path, err)
289316
}
290317

291-
err = migrateAllBranchesLogCmd(&rootNode)
318+
err = migrateAllBranchesLogCmd(&rootNode, changes)
292319
if err != nil {
293-
return nil, fmt.Errorf("Couldn't migrate config file at `%s`: %s", path, err)
320+
return nil, false, fmt.Errorf("Couldn't migrate config file at `%s`: %s", path, err)
294321
}
295322

296323
// Add more migrations here...
297324

298-
if !reflect.DeepEqual(rootNode, originalCopy) {
299-
newContent, err := yaml_utils.YamlMarshal(&rootNode)
300-
if err != nil {
301-
return nil, fmt.Errorf("Failed to remarsal!\n %w", err)
302-
}
303-
return newContent, nil
304-
} else {
305-
return content, nil
325+
if reflect.DeepEqual(rootNode, originalCopy) {
326+
return nil, false, nil
306327
}
328+
329+
newContent, err := yaml_utils.YamlMarshal(&rootNode)
330+
if err != nil {
331+
return nil, false, fmt.Errorf("Failed to remarsal!\n %w", err)
332+
}
333+
return newContent, true, nil
307334
}
308335

309-
func changeNullKeybindingsToDisabled(rootNode *yaml.Node) error {
336+
func changeNullKeybindingsToDisabled(rootNode *yaml.Node, changes *ChangesSet) error {
310337
return yaml_utils.Walk(rootNode, func(node *yaml.Node, path string) {
311338
if strings.HasPrefix(path, "keybinding.") && node.Kind == yaml.ScalarNode && node.Tag == "!!null" {
312339
node.Value = "<disabled>"
313340
node.Tag = "!!str"
341+
changes.Add(fmt.Sprintf("Changed 'null' to '<disabled>' for keybinding '%s'", path))
314342
}
315343
})
316344
}
317345

318-
func changeElementToSequence(rootNode *yaml.Node, path []string) error {
346+
func changeElementToSequence(rootNode *yaml.Node, path []string, changes *ChangesSet) error {
319347
return yaml_utils.TransformNode(rootNode, path, func(node *yaml.Node) error {
320348
if node.Kind == yaml.MappingNode {
321349
nodeContentCopy := node.Content
@@ -327,13 +355,15 @@ func changeElementToSequence(rootNode *yaml.Node, path []string) error {
327355
Content: nodeContentCopy,
328356
}}
329357

358+
changes.Add(fmt.Sprintf("Changed '%s' to an array of strings", strings.Join(path, ".")))
359+
330360
return nil
331361
}
332362
return nil
333363
})
334364
}
335365

336-
func changeCommitPrefixesMap(rootNode *yaml.Node) error {
366+
func changeCommitPrefixesMap(rootNode *yaml.Node, changes *ChangesSet) error {
337367
return yaml_utils.TransformNode(rootNode, []string{"git", "commitPrefixes"}, func(prefixesNode *yaml.Node) error {
338368
if prefixesNode.Kind == yaml.MappingNode {
339369
for _, contentNode := range prefixesNode.Content {
@@ -346,14 +376,15 @@ func changeCommitPrefixesMap(rootNode *yaml.Node) error {
346376
Kind: yaml.MappingNode,
347377
Content: nodeContentCopy,
348378
}}
379+
changes.Add("Changed 'git.commitPrefixes' elements to arrays of strings")
349380
}
350381
}
351382
}
352383
return nil
353384
})
354385
}
355386

356-
func changeCustomCommandStreamAndOutputToOutputEnum(rootNode *yaml.Node) error {
387+
func changeCustomCommandStreamAndOutputToOutputEnum(rootNode *yaml.Node, changes *ChangesSet) error {
357388
return yaml_utils.Walk(rootNode, func(node *yaml.Node, path string) {
358389
// We are being lazy here and rely on the fact that the only mapping
359390
// nodes in the tree under customCommands are actual custom commands. If
@@ -364,16 +395,25 @@ func changeCustomCommandStreamAndOutputToOutputEnum(rootNode *yaml.Node) error {
364395
if streamKey, streamValue := yaml_utils.RemoveKey(node, "subprocess"); streamKey != nil {
365396
if streamValue.Kind == yaml.ScalarNode && streamValue.Value == "true" {
366397
output = "terminal"
398+
changes.Add("Changed 'subprocess: true' to 'output: terminal' in custom command")
399+
} else {
400+
changes.Add("Deleted redundant 'subprocess: false' in custom command")
367401
}
368402
}
369403
if streamKey, streamValue := yaml_utils.RemoveKey(node, "stream"); streamKey != nil {
370404
if streamValue.Kind == yaml.ScalarNode && streamValue.Value == "true" && output == "" {
371405
output = "log"
406+
changes.Add("Changed 'stream: true' to 'output: log' in custom command")
407+
} else {
408+
changes.Add(fmt.Sprintf("Deleted redundant 'stream: %v' property in custom command", streamValue.Value))
372409
}
373410
}
374411
if streamKey, streamValue := yaml_utils.RemoveKey(node, "showOutput"); streamKey != nil {
375412
if streamValue.Kind == yaml.ScalarNode && streamValue.Value == "true" && output == "" {
413+
changes.Add("Changed 'showOutput: true' to 'output: popup' in custom command")
376414
output = "popup"
415+
} else {
416+
changes.Add(fmt.Sprintf("Deleted redundant 'showOutput: %v' property in custom command", streamValue.Value))
377417
}
378418
}
379419
if output != "" {
@@ -397,7 +437,7 @@ func changeCustomCommandStreamAndOutputToOutputEnum(rootNode *yaml.Node) error {
397437
// a single element at `allBranchesLogCmd` and the sequence at `allBranchesLogCmds`.
398438
// Some users have explicitly set `allBranchesLogCmd` to be an empty string in order
399439
// to remove it, so in that case we just delete the element, and add nothing to the list
400-
func migrateAllBranchesLogCmd(rootNode *yaml.Node) error {
440+
func migrateAllBranchesLogCmd(rootNode *yaml.Node, changes *ChangesSet) error {
401441
return yaml_utils.TransformNode(rootNode, []string{"git"}, func(gitNode *yaml.Node) error {
402442
cmdKeyNode, cmdValueNode := yaml_utils.LookupKey(gitNode, "allBranchesLogCmd")
403443
// Nothing to do if they do not have the deprecated item
@@ -406,6 +446,7 @@ func migrateAllBranchesLogCmd(rootNode *yaml.Node) error {
406446
}
407447

408448
cmdsKeyNode, cmdsValueNode := yaml_utils.LookupKey(gitNode, "allBranchesLogCmds")
449+
var change string
409450
if cmdsKeyNode == nil {
410451
// Create empty sequence node and attach it onto the root git node
411452
// We will later populate it with the individual allBranchesLogCmd record
@@ -415,17 +456,24 @@ func migrateAllBranchesLogCmd(rootNode *yaml.Node) error {
415456
cmdsKeyNode,
416457
cmdsValueNode,
417458
)
418-
} else if cmdsValueNode.Kind != yaml.SequenceNode {
419-
return errors.New("You should have an allBranchesLogCmds defined as a sequence!")
459+
change = "Created git.allBranchesLogCmds array containing value of git.allBranchesLogCmd"
460+
} else {
461+
if cmdsValueNode.Kind != yaml.SequenceNode {
462+
return errors.New("You should have an allBranchesLogCmds defined as a sequence!")
463+
}
464+
465+
change = "Prepended git.allBranchesLogCmd value to git.allBranchesLogCmds array"
420466
}
421467

422468
if cmdValueNode.Value != "" {
423469
// Prepending the individual element to make it show up first in the list, which was prior behavior
424470
cmdsValueNode.Content = utils.Prepend(cmdsValueNode.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: cmdValueNode.Value})
471+
changes.Add(change)
425472
}
426473

427474
// Clear out the existing allBranchesLogCmd, now that we have migrated it into the list
428475
_, _ = yaml_utils.RemoveKey(gitNode, "allBranchesLogCmd")
476+
changes.Add("Removed obsolete git.allBranchesLogCmd")
429477

430478
return nil
431479
})
@@ -471,7 +519,7 @@ func (c *AppConfig) GetUserConfigDir() string {
471519

472520
func (c *AppConfig) ReloadUserConfigForRepo(repoConfigFiles []*ConfigFile) error {
473521
configFiles := append(c.globalUserConfigFiles, repoConfigFiles...)
474-
userConfig, err := loadUserConfigWithDefaults(configFiles)
522+
userConfig, err := loadUserConfigWithDefaults(configFiles, true)
475523
if err != nil {
476524
return err
477525
}
@@ -496,7 +544,7 @@ func (c *AppConfig) ReloadChangedUserConfigFiles() (error, bool) {
496544
return nil, false
497545
}
498546

499-
userConfig, err := loadUserConfigWithDefaults(c.userConfigFiles)
547+
userConfig, err := loadUserConfigWithDefaults(c.userConfigFiles, true)
500548
if err != nil {
501549
return err, false
502550
}

0 commit comments

Comments
 (0)