From a1da4ce862c59c846bbde0b769df770f62e0958c Mon Sep 17 00:00:00 2001 From: kuyacarlo <106532351+kuyacarlo@users.noreply.github.com> Date: Sat, 27 Jun 2026 02:57:55 +0800 Subject: [PATCH 1/5] Change {Key: user.xxx, Value: xxx} to {user.xxx: xxx} --- cmd/add.go | 24 +++----------- cmd/add_test.go | 16 ++++----- cmd/export_test.go | 8 ++--- cmd/import.go | 6 ++-- cmd/import_test.go | 2 +- cmd/interfaces.go | 2 +- cmd/list.go | 7 ++-- cmd/list_test.go | 6 ++-- cmd/mock.go | 6 ++-- cmd/unuse.go | 9 +++-- cmd/unuse_test.go | 12 +++++-- cmd/use.go | 4 +-- cmd/use_test.go | 6 ++-- go.mod | 4 +-- internal/config/config.go | 59 +++++++++++++++++++++------------ internal/config/config_test.go | 32 +++++++++--------- internal/oldconfig/oldconfig.go | 56 +++++++++++++++++++++++++++++++ 17 files changed, 164 insertions(+), 95 deletions(-) create mode 100644 internal/oldconfig/oldconfig.go diff --git a/cmd/add.go b/cmd/add.go index ad4c3bd..effea9c 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -8,7 +8,6 @@ import ( "github.com/spf13/cobra" - "github.com/dotzero/git-profile/internal/config" "github.com/dotzero/git-profile/internal/ui" ) @@ -112,10 +111,9 @@ func profileUpdateEntries( } if entries, ok := cfg.Lookup(profile); ok { - values := entriesToMap(entries) - formData.UserName = values[userNameKey] - formData.UserEmail = values[userEmailKey] - formData.UserSigningKey = values[userSigningKeyKey] + formData.UserName = entries[userNameKey] + formData.UserEmail = entries[userEmailKey] + formData.UserSigningKey = entries[userSigningKeyKey] } result, err := editProfileFields(formData, cmd.InOrStdin(), cmd.OutOrStdout()) @@ -123,11 +121,7 @@ func profileUpdateEntries( return err } - currentValues := map[string]string{} - if entries, ok := cfg.Lookup(result.Profile); ok { - currentValues = entriesToMap(entries) - } - + currentValues, _ := cfg.Lookup(result.Profile) changed := 0 values := map[string]string{ @@ -165,13 +159,3 @@ func profileUpdateEntries( return nil } - -func entriesToMap(entries []config.Entry) map[string]string { - values := make(map[string]string, len(entries)) - - for _, entry := range entries { - values[entry.Key] = entry.Value - } - - return values -} diff --git a/cmd/add_test.go b/cmd/add_test.go index e3e5ddf..830b3e1 100644 --- a/cmd/add_test.go +++ b/cmd/add_test.go @@ -54,10 +54,10 @@ func TestAddInteractiveStoresOnlyChangedFilledValues(t *testing.T) { is := is.New(t) cfg := &storageMock{ - LookupFunc: func(name string) ([]config.Entry, bool) { - return []config.Entry{ - {Key: userNameKey, Value: "Jane Doe"}, - {Key: userEmailKey, Value: "old@example.com"}, + LookupFunc: func(name string) (config.Entry, bool) { + return config.Entry{ + userNameKey: "Jane Doe", + userEmailKey: "old@example.com", }, true }, SaveFunc: func(filename string) error { @@ -105,10 +105,10 @@ func TestAddInteractiveSkipsSaveWhenNothingChanged(t *testing.T) { is := is.New(t) cfg := &storageMock{ - LookupFunc: func(name string) ([]config.Entry, bool) { - return []config.Entry{ - {Key: userNameKey, Value: "Jane Doe"}, - {Key: userEmailKey, Value: "work@example.com"}, + LookupFunc: func(name string) (config.Entry, bool) { + return config.Entry{ + userNameKey: "Jane Doe", + userEmailKey: "work@example.com", }, true }, SaveFunc: func(filename string) error { diff --git a/cmd/export_test.go b/cmd/export_test.go index 3136e02..cdac65b 100644 --- a/cmd/export_test.go +++ b/cmd/export_test.go @@ -13,9 +13,9 @@ func TestExport(t *testing.T) { is := is.New(t) cfg := &storageMock{ - LookupFunc: func(name string) ([]config.Entry, bool) { - return []config.Entry{ - {Key: "user.email", Value: "work@example.com"}, + LookupFunc: func(name string) (config.Entry, bool) { + return config.Entry{ + "user.email": "work@example.com", }, true }, } @@ -29,5 +29,5 @@ func TestExport(t *testing.T) { err := cmd.Execute() is.NoErr(err) - is.Equal(trim(b.String()), `[{"key":"user.email","value":"work@example.com"}]`) + is.Equal(trim(b.String()), `{"user.email":"work@example.com"}`) } diff --git a/cmd/import.go b/cmd/import.go index 407dadf..4a413cf 100644 --- a/cmd/import.go +++ b/cmd/import.go @@ -23,7 +23,7 @@ func Import(cfg storage) *cobra.Command { profile := args[0] filename, _ := cmd.Flags().GetString("config") - var entries []config.Entry + var entries config.Entry err := json.Unmarshal([]byte(args[1]), &entries) if err != nil { @@ -31,8 +31,8 @@ func Import(cfg storage) *cobra.Command { os.Exit(1) } - for _, entry := range entries { - cfg.Store(profile, entry.Key, entry.Value) + for _, key := range []string{userNameKey, userEmailKey, userSigningKeyKey} { + cfg.Store(profile, key, entries[key]) } err = cfg.Save(filename) diff --git a/cmd/import_test.go b/cmd/import_test.go index f4b25f8..13f3dd1 100644 --- a/cmd/import_test.go +++ b/cmd/import_test.go @@ -22,7 +22,7 @@ func TestImport(t *testing.T) { cmd := Import(cfg) cmd.SetOut(&b) - cmd.SetArgs([]string{"profile", `[{"key":"user.email","value":"work@example.com"}]`}) + cmd.SetArgs([]string{"profile", `{"user.email": "work@example.com"}`}) err := cmd.Execute() is.NoErr(err) diff --git a/cmd/interfaces.go b/cmd/interfaces.go index 3c68fe4..fea161a 100644 --- a/cmd/interfaces.go +++ b/cmd/interfaces.go @@ -8,7 +8,7 @@ import ( type storage interface { Len() int - Lookup(name string) ([]config.Entry, bool) + Lookup(name string) (config.Entry, bool) Names() []string Delete(profile string, value string) bool DeleteProfile(profile string) bool diff --git a/cmd/list.go b/cmd/list.go index 766a969..1d1d5b3 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -23,8 +23,11 @@ func List(cfg storage, v vcs) *cobra.Command { ui.Println(cmd, ui.SuccessStyle, "- %s:", name) profile, _ := cfg.Lookup(name) - for _, entry := range profile { - ui.Println(cmd, ui.DefaultStyle, " %s: %s", entry.Key, entry.Value) + for _, key := range []string{userNameKey, userEmailKey, userSigningKeyKey} { + if profile[key] == "" { + continue + } + ui.Println(cmd, ui.DefaultStyle, " %s: %s", key, profile[key]) } } }, diff --git a/cmd/list_test.go b/cmd/list_test.go index bd08990..d5d3061 100644 --- a/cmd/list_test.go +++ b/cmd/list_test.go @@ -19,9 +19,9 @@ func TestList(t *testing.T) { NamesFunc: func() []string { return []string{"home"} }, - LookupFunc: func(name string) ([]config.Entry, bool) { - return []config.Entry{ - {Key: "user.email", Value: "work@example.com"}, + LookupFunc: func(name string) (config.Entry, bool) { + return config.Entry{ + "user.email": "work@example.com", }, true }, } diff --git a/cmd/mock.go b/cmd/mock.go index 1432cd4..1cbb4d9 100644 --- a/cmd/mock.go +++ b/cmd/mock.go @@ -26,7 +26,7 @@ import ( // LoadFunc: func(filename string) error { // panic("mock out the Load method") // }, -// LookupFunc: func(name string) ([]config.Entry, bool) { +// LookupFunc: func(name string) (config.Entry, bool) { // panic("mock out the Lookup method") // }, // NamesFunc: func() []string { @@ -58,7 +58,7 @@ type storageMock struct { LoadFunc func(filename string) error // LookupFunc mocks the Lookup method. - LookupFunc func(name string) ([]config.Entry, bool) + LookupFunc func(name string) (config.Entry, bool) // NamesFunc mocks the Names method. NamesFunc func() []string @@ -252,7 +252,7 @@ func (mock *storageMock) LoadCalls() []struct { } // Lookup calls LookupFunc. -func (mock *storageMock) Lookup(name string) ([]config.Entry, bool) { +func (mock *storageMock) Lookup(name string) (config.Entry, bool) { if mock.LookupFunc == nil { panic("storageMock.LookupFunc: method is nil but storage.Lookup was just called") } diff --git a/cmd/unuse.go b/cmd/unuse.go index 19c0154..b364333 100644 --- a/cmd/unuse.go +++ b/cmd/unuse.go @@ -53,14 +53,17 @@ func unuseProfileResolve(args []string, v vcs) (string, error) { } func profileUnapply(cmd *cobra.Command, cfg storage, v vcs, profile string) { - entries, ok := cfg.Lookup(profile) + _, ok := cfg.Lookup(profile) if !ok { ui.PrintErrln(cmd, ui.ErrorStyle, "There is no profile with `%s` name", profile) os.Exit(0) } - for _, entry := range entries { - err := v.Unset(entry.Key) + for _, key := range []string{userNameKey, userEmailKey, userSigningKeyKey} { + if val, _ := v.Get(key); val == "" { + continue + } + err := v.Unset(key) if err != nil { ui.PrintErrln(cmd, ui.ErrorStyle, "Unable to interact with git to remove current profile: %s", err) os.Exit(1) diff --git a/cmd/unuse_test.go b/cmd/unuse_test.go index 31cc0b2..a1c2df2 100644 --- a/cmd/unuse_test.go +++ b/cmd/unuse_test.go @@ -17,9 +17,9 @@ func TestUnuse(t *testing.T) { LenFunc: func() int { return 1 }, - LookupFunc: func(name string) ([]config.Entry, bool) { - return []config.Entry{ - {Key: "user.email", Value: "work@example.com"}, + LookupFunc: func(name string) (config.Entry, bool) { + return config.Entry{ + "user.email": "work@example.com", }, true }, } @@ -30,6 +30,12 @@ func TestUnuse(t *testing.T) { IsRepositoryFunc: func() bool { return true }, + GetFunc: func(key string) (string, error) { + if key == userEmailKey { + return "work@example.com", nil + } + return "", nil + }, UnsetFunc: func(key string) error { unset = append(unset, key) return nil diff --git a/cmd/use.go b/cmd/use.go index a2536c9..c228bd8 100644 --- a/cmd/use.go +++ b/cmd/use.go @@ -76,8 +76,8 @@ func profileApply(cmd *cobra.Command, cfg storage, v vcs, profile string) { os.Exit(1) } - for _, entry := range entries { - err := v.Set(entry.Key, entry.Value) + for _, key := range []string{userNameKey, userEmailKey, userSigningKeyKey} { + err := v.Set(key, entries[key]) if err != nil { ui.PrintErrln(cmd, ui.ErrorStyle, "Unable to interact with git to store current profile: %s", err) os.Exit(1) diff --git a/cmd/use_test.go b/cmd/use_test.go index a57278a..2718b59 100644 --- a/cmd/use_test.go +++ b/cmd/use_test.go @@ -18,9 +18,9 @@ func TestUse(t *testing.T) { LenFunc: func() int { return 1 }, - LookupFunc: func(name string) ([]config.Entry, bool) { - return []config.Entry{ - {Key: "user.email", Value: "work@example.com"}, + LookupFunc: func(name string) (config.Entry, bool) { + return config.Entry{ + "user.email": "work@example.com", }, true }, } diff --git a/go.mod b/go.mod index 629dbd5..e892e15 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,8 @@ go 1.26 require ( charm.land/huh/v2 v2.0.3 + charm.land/lipgloss/v2 v2.0.1 + github.com/charmbracelet/x/ansi v0.11.6 github.com/matryer/is v1.4.1 github.com/mitchellh/go-homedir v1.1.0 github.com/spf13/cobra v1.4.0 @@ -12,12 +14,10 @@ require ( require ( charm.land/bubbles/v2 v2.0.0 // indirect charm.land/bubbletea/v2 v2.0.2 // indirect - charm.land/lipgloss/v2 v2.0.1 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect - github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect diff --git a/internal/config/config.go b/internal/config/config.go index da98952..b82545e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,23 +4,22 @@ import ( "encoding/json" "os" "sort" + + "github.com/dotzero/git-profile/internal/oldconfig" ) // Entry is the entry in config file -type Entry struct { - Key string `json:"key"` - Value string `json:"value"` -} +type Entry map[string]string // Config is the config storage type Config struct { - Profiles map[string][]Entry `json:"profiles"` + Profiles map[string]Entry `json:"profiles"` } // New initializes and returns a new Config func New() *Config { return &Config{ - Profiles: make(map[string][]Entry), + Profiles: make(map[string]Entry), } } @@ -30,7 +29,7 @@ func (c *Config) Len() int { } // Lookup returns the profile with the given name -func (c *Config) Lookup(name string) ([]Entry, bool) { +func (c *Config) Lookup(name string) (Entry, bool) { entries, ok := c.Profiles[name] return entries, ok @@ -50,23 +49,15 @@ func (c *Config) Names() []string { } // Delete deletes the value for a key in the profile -func (c *Config) Delete(profile string, value string) bool { +func (c *Config) Delete(profile string, key string) bool { if _, ok := c.Profiles[profile]; !ok { return false } - entries := c.Profiles[profile][:0] + delete(c.Profiles[profile], key) - for _, e := range c.Profiles[profile] { - if e.Key != value { - entries = append(entries, e) - } - } - - delete(c.Profiles, profile) - - if len(entries) > 0 { - c.Profiles[profile] = entries + if len(c.Profiles[profile]) == 0 { + delete(c.Profiles, profile) } return true @@ -87,7 +78,11 @@ func (c *Config) DeleteProfile(profile string) bool { func (c *Config) Store(profile string, key string, value string) { c.Delete(profile, key) - c.Profiles[profile] = append(c.Profiles[profile], Entry{key, value}) + if _, ok := c.Profiles[profile]; !ok { + c.Profiles[profile] = make(Entry) + } + + c.Profiles[profile][key] = value } // Save stores profiles to json file @@ -114,5 +109,27 @@ func (c *Config) Load(filename string) (err error) { return err } - return json.Unmarshal(body, c) + // Try to unmarshal as new format (map) + err = json.Unmarshal(body, c) + if err == nil { + return nil // Success with new format + } + + // Try to unmarshal as old format (array) + oldCfg := oldconfig.New() + err = json.Unmarshal(body, oldCfg) + if err != nil { + // Neither format worked, return original error + return json.Unmarshal(body, c) + } + + // Convert old format to new format + for profileName, oldEntries := range oldCfg.Profiles { + for _, entry := range oldEntries { + c.Store(profileName, entry.Key, entry.Value) + } + } + + // Auto-save converted format + return c.Save(filename) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b4fe812..cc11bda 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -10,10 +10,10 @@ func TestDelete(t *testing.T) { is := is.New(t) cfg := &Config{ - Profiles: map[string][]Entry{ + Profiles: map[string]Entry{ "home": { - {"user.email", "work@example.com"}, - {"user.name", "John Doe"}, + "user.email": "work@example.com", + "user.name": "John Doe", }, }, } @@ -30,9 +30,9 @@ func TestDeleteProfile(t *testing.T) { is := is.New(t) cfg := &Config{ - Profiles: map[string][]Entry{ + Profiles: map[string]Entry{ "home": { - {"user.email", "work@example.com"}, + "user.email": "work@example.com", }, }, } @@ -57,9 +57,9 @@ func TestStoreValue(t *testing.T) { key: "key1", value: "value1", expected: &Config{ - Profiles: map[string][]Entry{ + Profiles: map[string]Entry{ "foo": { - {"key1", "value1"}, + "key1": "value1", }, }, }, @@ -69,9 +69,9 @@ func TestStoreValue(t *testing.T) { key: "key1", value: "value2", expected: &Config{ - Profiles: map[string][]Entry{ + Profiles: map[string]Entry{ "foo": { - {"key1", "value2"}, + "key1": "value2", }, }, }, @@ -81,10 +81,10 @@ func TestStoreValue(t *testing.T) { key: "key2", value: "value2", expected: &Config{ - Profiles: map[string][]Entry{ + Profiles: map[string]Entry{ "foo": { - {"key1", "value2"}, - {"key2", "value2"}, + "key1": "value2", + "key2": "value2", }, }, }, @@ -94,13 +94,13 @@ func TestStoreValue(t *testing.T) { key: "key1", value: "value1", expected: &Config{ - Profiles: map[string][]Entry{ + Profiles: map[string]Entry{ "foo": { - {"key1", "value2"}, - {"key2", "value2"}, + "key1": "value2", + "key2": "value2", }, "bar": { - {"key1", "value1"}, + "key1": "value1", }, }, }, diff --git a/internal/oldconfig/oldconfig.go b/internal/oldconfig/oldconfig.go new file mode 100644 index 0000000..916c30b --- /dev/null +++ b/internal/oldconfig/oldconfig.go @@ -0,0 +1,56 @@ +package oldconfig + +import ( + "encoding/json" + "os" +) + +// Entry is the entry in config file +type OldEntry struct { + Key string `json:"key"` + Value string `json:"value"` +} + +// Config is the config storage +type OldConfig struct { + Profiles map[string][]OldEntry `json:"profiles"` +} + +// New initializes and returns a new Config +func New() *OldConfig { + return &OldConfig{ + Profiles: make(map[string][]OldEntry), + } +} + +// Len returns number of profiles +func (c *OldConfig) Len() int { + return len(c.Profiles) +} + +// Save stores profiles to json file +func (c *OldConfig) Save(filename string) error { + data, err := json.MarshalIndent(c, "", " ") + if err != nil { + return err + } + + return os.WriteFile(filename, data, 0o644) //nolint:gosec +} + +// Load profiles from json file +func (c *OldConfig) Load(filename string) (err error) { + if _, err = os.Stat(filename); os.IsNotExist(err) { + err = c.Save(filename) + if err != nil { + return err + } + } + + body, err := os.ReadFile(filename) //nolint:gosec + if err != nil { + return err + } + + return json.Unmarshal(body, c) +} From 99426b0d22204c057c9427f113bdba09abc48504 Mon Sep 17 00:00:00 2001 From: kuyacarlo <106532351+kuyacarlo@users.noreply.github.com> Date: Wed, 1 Jul 2026 02:52:33 +0800 Subject: [PATCH 2/5] feat: add support for arbitrary keys (ex. `core.autocrlf`) - test: add testing for old and new config formats - feat: sort by configuration keys when using `list` command --- cmd/import.go | 20 +++++++++++---- cmd/import_test.go | 64 ++++++++++++++++++++++++++++++++++++---------- cmd/list.go | 12 ++++++--- cmd/list_test.go | 5 ++-- cmd/unuse.go | 7 ++--- cmd/unuse_test.go | 17 ++++++------ cmd/use.go | 4 +-- cmd/use_test.go | 10 +++++++- 8 files changed, 98 insertions(+), 41 deletions(-) diff --git a/cmd/import.go b/cmd/import.go index 4a413cf..08cc2b0 100644 --- a/cmd/import.go +++ b/cmd/import.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" "github.com/dotzero/git-profile/internal/config" + "github.com/dotzero/git-profile/internal/oldconfig" "github.com/dotzero/git-profile/internal/ui" ) @@ -18,7 +19,7 @@ func Import(cfg storage) *cobra.Command { Short: "Import a profile", Long: "Import a profile from JSON.", Args: cobra.ExactArgs(2), - Example: `git-profile import my-profile '[{"key":"user.email","value":"work@example.com"}]'`, + Example: `git-profile import my-profile '{"user.email":"work@example.com"}'`, Run: func(cmd *cobra.Command, args []string) { profile := args[0] filename, _ := cmd.Flags().GetString("config") @@ -27,12 +28,21 @@ func Import(cfg storage) *cobra.Command { err := json.Unmarshal([]byte(args[1]), &entries) if err != nil { - ui.PrintErrln(cmd, ui.ErrorStyle, "Unable to decode profile values: %s", err) - os.Exit(1) + // Try to unmarshal as old format (array) + var oldEntries []oldconfig.OldEntry + errOld := json.Unmarshal([]byte(args[1]), &oldEntries) + if errOld != nil { + ui.PrintErrln(cmd, ui.ErrorStyle, "Unable to decode profile values: %s", err) + os.Exit(1) + } + entries = make(config.Entry) + for _, entry := range oldEntries { + entries[entry.Key] = entry.Value + } } - for _, key := range []string{userNameKey, userEmailKey, userSigningKeyKey} { - cfg.Store(profile, key, entries[key]) + for key, val := range entries { + cfg.Store(profile, key, val) } err = cfg.Save(filename) diff --git a/cmd/import_test.go b/cmd/import_test.go index 13f3dd1..5589b88 100644 --- a/cmd/import_test.go +++ b/cmd/import_test.go @@ -8,23 +8,59 @@ import ( ) func TestImport(t *testing.T) { - is := is.New(t) + t.Run("map format", func(t *testing.T) { + is := is.New(t) - cfg := &storageMock{ - SaveFunc: func(filename string) error { - return nil - }, - StoreFunc: func(profile string, key string, value string) {}, - } + stored := make(map[string]string) + cfg := &storageMock{ + SaveFunc: func(filename string) error { + return nil + }, + StoreFunc: func(profile string, key string, value string) { + is.Equal(profile, "profile") + stored[key] = value + }, + } - var b bytes.Buffer + var b bytes.Buffer + cmd := Import(cfg) + cmd.SetOut(&b) + cmd.SetArgs([]string{"profile", `{"user.email": "work@example.com", "core.autocrlf": "input"}`}) + err := cmd.Execute() - cmd := Import(cfg) + is.NoErr(err) + is.Equal(trim(b.String()), "Successfully imported `profile` profile.") + is.Equal(stored, map[string]string{ + "user.email": "work@example.com", + "core.autocrlf": "input", + }) + }) - cmd.SetOut(&b) - cmd.SetArgs([]string{"profile", `{"user.email": "work@example.com"}`}) - err := cmd.Execute() + t.Run("array format", func(t *testing.T) { + is := is.New(t) - is.NoErr(err) - is.Equal(trim(b.String()), "Successfully imported `profile` profile.") + stored := make(map[string]string) + cfg := &storageMock{ + SaveFunc: func(filename string) error { + return nil + }, + StoreFunc: func(profile string, key string, value string) { + is.Equal(profile, "profile") + stored[key] = value + }, + } + + var b bytes.Buffer + cmd := Import(cfg) + cmd.SetOut(&b) + cmd.SetArgs([]string{"profile", `[{"key": "user.email", "value": "work@example.com"}, {"key": "core.autocrlf", "value": "input"}]`}) + err := cmd.Execute() + + is.NoErr(err) + is.Equal(trim(b.String()), "Successfully imported `profile` profile.") + is.Equal(stored, map[string]string{ + "user.email": "work@example.com", + "core.autocrlf": "input", + }) + }) } diff --git a/cmd/list.go b/cmd/list.go index 1d1d5b3..cffd18f 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -1,6 +1,8 @@ package cmd import ( + "sort" + "github.com/spf13/cobra" "github.com/dotzero/git-profile/internal/ui" @@ -23,10 +25,12 @@ func List(cfg storage, v vcs) *cobra.Command { ui.Println(cmd, ui.SuccessStyle, "- %s:", name) profile, _ := cfg.Lookup(name) - for _, key := range []string{userNameKey, userEmailKey, userSigningKeyKey} { - if profile[key] == "" { - continue - } + keys := make([]string, 0, len(profile)) + for key := range profile { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { ui.Println(cmd, ui.DefaultStyle, " %s: %s", key, profile[key]) } } diff --git a/cmd/list_test.go b/cmd/list_test.go index d5d3061..8514445 100644 --- a/cmd/list_test.go +++ b/cmd/list_test.go @@ -21,7 +21,8 @@ func TestList(t *testing.T) { }, LookupFunc: func(name string) (config.Entry, bool) { return config.Entry{ - "user.email": "work@example.com", + "user.email": "work@example.com", + "core.autocrlf": "input", }, true }, } @@ -43,5 +44,5 @@ func TestList(t *testing.T) { err := cmd.Execute() is.NoErr(err) - is.Equal(trim(b.String()), "Available profiles:\n- home:\n user.email: work@example.com") + is.Equal(trim(b.String()), "Available profiles:\n- home:\n core.autocrlf: input\n user.email: work@example.com") } diff --git a/cmd/unuse.go b/cmd/unuse.go index b364333..ee88f56 100644 --- a/cmd/unuse.go +++ b/cmd/unuse.go @@ -53,16 +53,13 @@ func unuseProfileResolve(args []string, v vcs) (string, error) { } func profileUnapply(cmd *cobra.Command, cfg storage, v vcs, profile string) { - _, ok := cfg.Lookup(profile) + entries, ok := cfg.Lookup(profile) if !ok { ui.PrintErrln(cmd, ui.ErrorStyle, "There is no profile with `%s` name", profile) os.Exit(0) } - for _, key := range []string{userNameKey, userEmailKey, userSigningKeyKey} { - if val, _ := v.Get(key); val == "" { - continue - } + for key := range entries { err := v.Unset(key) if err != nil { ui.PrintErrln(cmd, ui.ErrorStyle, "Unable to interact with git to remove current profile: %s", err) diff --git a/cmd/unuse_test.go b/cmd/unuse_test.go index a1c2df2..2857ca6 100644 --- a/cmd/unuse_test.go +++ b/cmd/unuse_test.go @@ -3,6 +3,7 @@ package cmd import ( "bytes" "fmt" + "sort" "testing" "github.com/matryer/is" @@ -19,7 +20,8 @@ func TestUnuse(t *testing.T) { }, LookupFunc: func(name string) (config.Entry, bool) { return config.Entry{ - "user.email": "work@example.com", + "user.email": "work@example.com", + "core.autocrlf": "input", }, true }, } @@ -30,12 +32,6 @@ func TestUnuse(t *testing.T) { IsRepositoryFunc: func() bool { return true }, - GetFunc: func(key string) (string, error) { - if key == userEmailKey { - return "work@example.com", nil - } - return "", nil - }, UnsetFunc: func(key string) error { unset = append(unset, key) return nil @@ -52,7 +48,12 @@ func TestUnuse(t *testing.T) { is.NoErr(err) is.Equal(trim(b.String()), "Successfully removed `profile` profile from current git repository.") - is.Equal(unset, []string{"user.email", currentProfileKey}) + is.Equal(len(unset), 3) + is.Equal(unset[2], currentProfileKey) + + keys := unset[:2] + sort.Strings(keys) + is.Equal(keys, []string{"core.autocrlf", "user.email"}) } func TestUnuseProfileResolveArg(t *testing.T) { diff --git a/cmd/use.go b/cmd/use.go index c228bd8..1eacba9 100644 --- a/cmd/use.go +++ b/cmd/use.go @@ -76,8 +76,8 @@ func profileApply(cmd *cobra.Command, cfg storage, v vcs, profile string) { os.Exit(1) } - for _, key := range []string{userNameKey, userEmailKey, userSigningKeyKey} { - err := v.Set(key, entries[key]) + for key, value := range entries { + err := v.Set(key, value) if err != nil { ui.PrintErrln(cmd, ui.ErrorStyle, "Unable to interact with git to store current profile: %s", err) os.Exit(1) diff --git a/cmd/use_test.go b/cmd/use_test.go index 2718b59..8b9f1f5 100644 --- a/cmd/use_test.go +++ b/cmd/use_test.go @@ -20,16 +20,19 @@ func TestUse(t *testing.T) { }, LookupFunc: func(name string) (config.Entry, bool) { return config.Entry{ - "user.email": "work@example.com", + "user.email": "work@example.com", + "core.autocrlf": "input", }, true }, } + setParams := make(map[string]string) vcs := &vcsMock{ IsRepositoryFunc: func() bool { return true }, SetFunc: func(key string, value string) error { + setParams[key] = value return nil }, } @@ -44,6 +47,11 @@ func TestUse(t *testing.T) { is.NoErr(err) is.Equal(trim(b.String()), "Successfully applied `profile` profile to current git repository.") + is.Equal(setParams, map[string]string{ + "current-profile.name": "profile", + "user.email": "work@example.com", + "core.autocrlf": "input", + }) } func TestProfileResolveInteractive(t *testing.T) { From 8de9dbb7c76bfc75bfedb47b538a8e35c6585cfe Mon Sep 17 00:00:00 2001 From: kuyacarlo <106532351+kuyacarlo@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:17:19 +0800 Subject: [PATCH 3/5] fix: satisfy revive exported comments and wsl spacing CI failed on mismatched OldEntry/OldConfig doc comments; also apply golangci wsl blank-line fixes so local act matches upstream lint. Co-authored-by: Cursor --- cmd/import.go | 2 ++ cmd/import_test.go | 4 ++++ cmd/list.go | 3 +++ internal/config/config.go | 1 + internal/oldconfig/oldconfig.go | 6 +++--- 5 files changed, 13 insertions(+), 3 deletions(-) diff --git a/cmd/import.go b/cmd/import.go index 08cc2b0..5a8cb13 100644 --- a/cmd/import.go +++ b/cmd/import.go @@ -30,11 +30,13 @@ func Import(cfg storage) *cobra.Command { if err != nil { // Try to unmarshal as old format (array) var oldEntries []oldconfig.OldEntry + errOld := json.Unmarshal([]byte(args[1]), &oldEntries) if errOld != nil { ui.PrintErrln(cmd, ui.ErrorStyle, "Unable to decode profile values: %s", err) os.Exit(1) } + entries = make(config.Entry) for _, entry := range oldEntries { entries[entry.Key] = entry.Value diff --git a/cmd/import_test.go b/cmd/import_test.go index 5589b88..6a5bd22 100644 --- a/cmd/import_test.go +++ b/cmd/import_test.go @@ -18,11 +18,13 @@ func TestImport(t *testing.T) { }, StoreFunc: func(profile string, key string, value string) { is.Equal(profile, "profile") + stored[key] = value }, } var b bytes.Buffer + cmd := Import(cfg) cmd.SetOut(&b) cmd.SetArgs([]string{"profile", `{"user.email": "work@example.com", "core.autocrlf": "input"}`}) @@ -46,11 +48,13 @@ func TestImport(t *testing.T) { }, StoreFunc: func(profile string, key string, value string) { is.Equal(profile, "profile") + stored[key] = value }, } var b bytes.Buffer + cmd := Import(cfg) cmd.SetOut(&b) cmd.SetArgs([]string{"profile", `[{"key": "user.email", "value": "work@example.com"}, {"key": "core.autocrlf", "value": "input"}]`}) diff --git a/cmd/list.go b/cmd/list.go index cffd18f..be1bd2e 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -25,11 +25,14 @@ func List(cfg storage, v vcs) *cobra.Command { ui.Println(cmd, ui.SuccessStyle, "- %s:", name) profile, _ := cfg.Lookup(name) + keys := make([]string, 0, len(profile)) for key := range profile { keys = append(keys, key) } + sort.Strings(keys) + for _, key := range keys { ui.Println(cmd, ui.DefaultStyle, " %s: %s", key, profile[key]) } diff --git a/internal/config/config.go b/internal/config/config.go index b82545e..b9851a7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -117,6 +117,7 @@ func (c *Config) Load(filename string) (err error) { // Try to unmarshal as old format (array) oldCfg := oldconfig.New() + err = json.Unmarshal(body, oldCfg) if err != nil { // Neither format worked, return original error diff --git a/internal/oldconfig/oldconfig.go b/internal/oldconfig/oldconfig.go index 916c30b..fd0da96 100644 --- a/internal/oldconfig/oldconfig.go +++ b/internal/oldconfig/oldconfig.go @@ -5,18 +5,18 @@ import ( "os" ) -// Entry is the entry in config file +// OldEntry is a single key/value pair in the legacy config file format. type OldEntry struct { Key string `json:"key"` Value string `json:"value"` } -// Config is the config storage +// OldConfig is the legacy config storage used for migration. type OldConfig struct { Profiles map[string][]OldEntry `json:"profiles"` } -// New initializes and returns a new Config +// New initializes and returns a new OldConfig. func New() *OldConfig { return &OldConfig{ Profiles: make(map[string][]OldEntry), From d05991e2fc991fc5ea3a28dba480b13ac382f735 Mon Sep 17 00:00:00 2001 From: kuyacarlo <106532351+kuyacarlo@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:13:06 +0800 Subject: [PATCH 4/5] fix: keep ~/.gitprofile legacy; use XDG for new format Avoid in-place migration of existing configs. Prefer the XDG path for the map format, and fall back to ~/.gitprofile in array format. Co-authored-by: Cursor --- README.md | 12 +- cmd/root.go | 19 +- go.mod | 1 - go.sum | 2 - internal/config/config.go | 204 +++++++++++++++-- internal/config/config_test.go | 214 ++++++++++++++++++ internal/oldconfig/oldconfig.go | 2 +- .../github.com/mitchellh/go-homedir/LICENSE | 21 -- .../github.com/mitchellh/go-homedir/README.md | 14 -- .../mitchellh/go-homedir/homedir.go | 167 -------------- vendor/modules.txt | 3 - 11 files changed, 426 insertions(+), 233 deletions(-) delete mode 100644 vendor/github.com/mitchellh/go-homedir/LICENSE delete mode 100644 vendor/github.com/mitchellh/go-homedir/README.md delete mode 100644 vendor/github.com/mitchellh/go-homedir/homedir.go diff --git a/README.md b/README.md index 4417a4c..22f5343 100644 --- a/README.md +++ b/README.md @@ -180,13 +180,15 @@ git-profile import work "$(cat work.json)" ## Config file -By default, Git Profile stores profiles in: +By default, Git Profile looks for profiles in this order: -```bash -~/.gitprofile -``` +1. `$XDG_CONFIG_HOME/git-profile/config.json` (or `~/.config/git-profile/config.json`) +2. `~/.gitprofile` (legacy path and array-based format) + +New installs create the XDG config file in the map-based format. +Existing `~/.gitprofile` files stay in the legacy array-based format and are not rewritten in place. -You can override it with: +You can override the path with: ```bash git-profile --config /path/to/file add work user.email work@example.com diff --git a/cmd/root.go b/cmd/root.go index 00f69a9..4360d82 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,7 +5,6 @@ import ( "os" "strings" - "github.com/mitchellh/go-homedir" "github.com/spf13/cobra" "github.com/dotzero/git-profile/internal/config" @@ -63,7 +62,7 @@ func (c *Cmd) Execute() { func (c *Cmd) init() { cobra.OnInitialize(func() { - filename, err := homedir.Expand(c.filename) + filename, err := resolveConfigPath(c.filename) if err != nil { c.PrintErrln(err) os.Exit(1) @@ -94,7 +93,21 @@ func (c *Cmd) init() { c.SetOutput(os.Stdout) c.SetErr(os.Stderr) - c.PersistentFlags().StringVarP(&c.filename, "config", "c", "~/.gitprofile", "config file") + c.PersistentFlags().StringVarP( + &c.filename, + "config", + "c", + "", + "config file (default: $XDG_CONFIG_HOME/git-profile/config.json or ~/.gitprofile)", + ) +} + +func resolveConfigPath(filename string) (string, error) { + if filename == "" { + return config.DefaultPath() + } + + return config.ExpandPath(filename) } func multiline(lines ...string) string { diff --git a/go.mod b/go.mod index e892e15..c6eddbb 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,6 @@ require ( charm.land/lipgloss/v2 v2.0.1 github.com/charmbracelet/x/ansi v0.11.6 github.com/matryer/is v1.4.1 - github.com/mitchellh/go-homedir v1.1.0 github.com/spf13/cobra v1.4.0 ) diff --git a/go.sum b/go.sum index 6f6800f..dbc08fe 100644 --- a/go.sum +++ b/go.sum @@ -59,8 +59,6 @@ github.com/matryer/moq v0.7.1 h1:/QaXqMAdOrLqlshW2z7SMS21jDi7aVrbW0wJrR+hhJk= github.com/matryer/moq v0.7.1/go.mod h1:IabIiFkaKCyHxej25INgFR+fnOxSZFMv2LYrU+ioyDs= github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= -github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= diff --git a/internal/config/config.go b/internal/config/config.go index b9851a7..57be23c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,18 +2,31 @@ package config import ( "encoding/json" + "fmt" "os" + "path/filepath" "sort" + "strings" "github.com/dotzero/git-profile/internal/oldconfig" ) +const ( + xdgAppDir = "git-profile" + xdgConfigFile = "config.json" + legacyFileName = ".gitprofile" +) + // Entry is the entry in config file type Entry map[string]string // Config is the config storage type Config struct { Profiles map[string]Entry `json:"profiles"` + + // legacy is true when the loaded file uses the array-based format. + // Legacy files keep that format on save. + legacy bool } // New initializes and returns a new Config @@ -23,6 +36,54 @@ func New() *Config { } } +// DefaultPath resolves the default config file path. +// It prefers $XDG_CONFIG_HOME/git-profile/config.json when that file exists. +// Otherwise it falls back to ~/.gitprofile when that file exists. +// If neither file exists, it returns the XDG path for new installs. +func DefaultPath() (string, error) { + xdgPath, err := xdgConfigPath() + if err != nil { + return "", err + } + + if fileExists(xdgPath) { + return xdgPath, nil + } + + legacyPath, err := legacyConfigPath() + if err != nil { + return "", err + } + + if fileExists(legacyPath) { + return legacyPath, nil + } + + return xdgPath, nil +} + +// ExpandPath expands a leading ~/ to the user home directory. +func ExpandPath(path string) (string, error) { + if path == "" { + return "", fmt.Errorf("empty path") + } + + if path == "~" || strings.HasPrefix(path, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + + if path == "~" { + return home, nil + } + + return filepath.Join(home, path[2:]), nil + } + + return filepath.Clean(path), nil +} + // Len returns number of profiles func (c *Config) Len() int { return len(c.Profiles) @@ -87,6 +148,14 @@ func (c *Config) Store(profile string, key string, value string) { // Save stores profiles to json file func (c *Config) Save(filename string) error { + if err := os.MkdirAll(filepath.Dir(filename), 0o755); err != nil { //nolint:gosec + return err + } + + if c.legacy { + return c.saveLegacy(filename) + } + data, err := json.MarshalIndent(c, "", " ") if err != nil { return err @@ -98,39 +167,142 @@ func (c *Config) Save(filename string) error { // Load profiles from json file func (c *Config) Load(filename string) (err error) { if _, err = os.Stat(filename); os.IsNotExist(err) { - err = c.Save(filename) + // New files use the map format, except explicit legacy paths. + c.legacy = isLegacyPath(filename) + + return c.Save(filename) + } + + body, err := os.ReadFile(filename) //nolint:gosec + if err != nil { + return err + } + + legacy, err := detectLegacyFormat(body, filename) + if err != nil { + return err + } + + c.legacy = legacy + c.Profiles = make(map[string]Entry) + + if legacy { + oldCfg := oldconfig.New() + + err = json.Unmarshal(body, oldCfg) if err != nil { return err } + + for profileName, oldEntries := range oldCfg.Profiles { + for _, entry := range oldEntries { + c.Store(profileName, entry.Key, entry.Value) + } + } + + return nil } - body, err := os.ReadFile(filename) //nolint:gosec + err = json.Unmarshal(body, c) if err != nil { return err } - // Try to unmarshal as new format (map) - err = json.Unmarshal(body, c) - if err == nil { - return nil // Success with new format + if c.Profiles == nil { + c.Profiles = make(map[string]Entry) } - // Try to unmarshal as old format (array) + return nil +} + +func (c *Config) saveLegacy(filename string) error { oldCfg := oldconfig.New() - err = json.Unmarshal(body, oldCfg) + for profileName, entries := range c.Profiles { + keys := make([]string, 0, len(entries)) + for key := range entries { + keys = append(keys, key) + } + + sort.Strings(keys) + + for _, key := range keys { + oldCfg.Profiles[profileName] = append(oldCfg.Profiles[profileName], oldconfig.OldEntry{ + Key: key, + Value: entries[key], + }) + } + } + + return oldCfg.Save(filename) +} + +func xdgConfigPath() (string, error) { + configHome := os.Getenv("XDG_CONFIG_HOME") + if configHome == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + + configHome = filepath.Join(home, ".config") + } + + return filepath.Join(configHome, xdgAppDir, xdgConfigFile), nil +} + +func legacyConfigPath() (string, error) { + home, err := os.UserHomeDir() if err != nil { - // Neither format worked, return original error - return json.Unmarshal(body, c) + return "", err } - // Convert old format to new format - for profileName, oldEntries := range oldCfg.Profiles { - for _, entry := range oldEntries { - c.Store(profileName, entry.Key, entry.Value) + return filepath.Join(home, legacyFileName), nil +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + + return err == nil +} + +func isLegacyPath(path string) bool { + return filepath.Base(path) == legacyFileName +} + +// detectLegacyFormat inspects profile values to choose array vs map encoding. +// Empty configs keep the legacy format when loaded from ~/.gitprofile. +func detectLegacyFormat(body []byte, filename string) (bool, error) { + var probe struct { + Profiles map[string]json.RawMessage `json:"profiles"` + } + + err := json.Unmarshal(body, &probe) + if err != nil { + return false, err + } + + if len(probe.Profiles) == 0 { + return isLegacyPath(filename), nil + } + + for _, raw := range probe.Profiles { + raw = trimJSONSpace(raw) + if len(raw) == 0 { + continue + } + + switch raw[0] { + case '[': + return true, nil + case '{': + return false, nil } } - // Auto-save converted format - return c.Save(filename) + return isLegacyPath(filename), nil +} + +func trimJSONSpace(raw json.RawMessage) json.RawMessage { + return json.RawMessage(strings.TrimSpace(string(raw))) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index cc11bda..7cef4b9 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,6 +1,10 @@ package config import ( + "encoding/json" + "os" + "path/filepath" + "strings" "testing" "github.com/matryer/is" @@ -116,3 +120,213 @@ func TestStoreValue(t *testing.T) { is.Equal(cfg.Profiles, c.expected.Profiles) } } + +func TestLoadLegacyDoesNotRewriteFile(t *testing.T) { + is := is.New(t) + + dir := t.TempDir() + path := filepath.Join(dir, ".gitprofile") + original := `{ + "profiles": { + "work": [ + { + "key": "user.email", + "value": "work@example.com" + }, + { + "key": "user.name", + "value": "John Doe" + } + ] + } +}` + + err := os.WriteFile(path, []byte(original), 0o644) + is.NoErr(err) + + cfg := New() + err = cfg.Load(path) + is.NoErr(err) + is.Equal(cfg.Profiles["work"]["user.email"], "work@example.com") + is.Equal(cfg.Profiles["work"]["user.name"], "John Doe") + is.True(cfg.legacy) + + body, err := os.ReadFile(path) + is.NoErr(err) + is.Equal(string(body), original) +} + +func TestSaveLegacyKeepsArrayFormat(t *testing.T) { + is := is.New(t) + + dir := t.TempDir() + path := filepath.Join(dir, ".gitprofile") + original := `{ + "profiles": { + "work": [ + { + "key": "user.email", + "value": "work@example.com" + } + ] + } +}` + + err := os.WriteFile(path, []byte(original), 0o644) + is.NoErr(err) + + cfg := New() + err = cfg.Load(path) + is.NoErr(err) + + cfg.Store("work", "user.name", "John Doe") + err = cfg.Save(path) + is.NoErr(err) + + body, err := os.ReadFile(path) + is.NoErr(err) + + var decoded map[string]map[string][]map[string]string + + err = json.Unmarshal(body, &decoded) + is.NoErr(err) + is.Equal(len(decoded["profiles"]["work"]), 2) + + keys := map[string]string{} + for _, entry := range decoded["profiles"]["work"] { + keys[entry["key"]] = entry["value"] + } + + is.Equal(keys["user.email"], "work@example.com") + is.Equal(keys["user.name"], "John Doe") + is.True(!strings.Contains(string(body), `"user.email": "work@example.com"`)) +} + +func TestLoadEmptyLegacyPathKeepsArrayFormat(t *testing.T) { + is := is.New(t) + + dir := t.TempDir() + path := filepath.Join(dir, ".gitprofile") + original := "{\n \"profiles\": {}\n}" + + err := os.WriteFile(path, []byte(original), 0o644) + is.NoErr(err) + + cfg := New() + err = cfg.Load(path) + is.NoErr(err) + is.True(cfg.legacy) + + cfg.Store("work", "user.email", "work@example.com") + err = cfg.Save(path) + is.NoErr(err) + + body, err := os.ReadFile(path) + is.NoErr(err) + is.True(strings.Contains(string(body), `"key": "user.email"`)) + is.True(strings.Contains(string(body), `"value": "work@example.com"`)) +} + +func TestLoadSaveMapFormat(t *testing.T) { + is := is.New(t) + + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + original := `{ + "profiles": { + "work": { + "user.email": "work@example.com" + } + } +}` + + err := os.WriteFile(path, []byte(original), 0o644) + is.NoErr(err) + + cfg := New() + err = cfg.Load(path) + is.NoErr(err) + is.True(!cfg.legacy) + + cfg.Store("work", "core.autocrlf", "input") + err = cfg.Save(path) + is.NoErr(err) + + body, err := os.ReadFile(path) + is.NoErr(err) + + var decoded Config + + err = json.Unmarshal(body, &decoded) + is.NoErr(err) + is.Equal(decoded.Profiles["work"]["user.email"], "work@example.com") + is.Equal(decoded.Profiles["work"]["core.autocrlf"], "input") +} + +func TestDefaultPathPrefersXDG(t *testing.T) { + is := is.New(t) + + home := t.TempDir() + xdgHome := filepath.Join(home, "xdg") + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", xdgHome) + + xdgPath := filepath.Join(xdgHome, "git-profile", "config.json") + legacyPath := filepath.Join(home, ".gitprofile") + + err := os.MkdirAll(filepath.Dir(xdgPath), 0o755) + is.NoErr(err) + err = os.WriteFile(xdgPath, []byte(`{"profiles":{}}`), 0o644) + is.NoErr(err) + err = os.WriteFile(legacyPath, []byte(`{"profiles":{}}`), 0o644) + is.NoErr(err) + + path, err := DefaultPath() + is.NoErr(err) + is.Equal(path, xdgPath) +} + +func TestDefaultPathFallsBackToLegacy(t *testing.T) { + is := is.New(t) + + home := t.TempDir() + xdgHome := filepath.Join(home, "xdg") + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", xdgHome) + + legacyPath := filepath.Join(home, ".gitprofile") + err := os.WriteFile(legacyPath, []byte(`{"profiles":{}}`), 0o644) + is.NoErr(err) + + path, err := DefaultPath() + is.NoErr(err) + is.Equal(path, legacyPath) +} + +func TestDefaultPathUsesXDGWhenMissing(t *testing.T) { + is := is.New(t) + + home := t.TempDir() + xdgHome := filepath.Join(home, "xdg") + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", xdgHome) + + path, err := DefaultPath() + is.NoErr(err) + is.Equal(path, filepath.Join(xdgHome, "git-profile", "config.json")) +} + +func TestExpandPath(t *testing.T) { + is := is.New(t) + + home := t.TempDir() + t.Setenv("HOME", home) + + path, err := ExpandPath("~/profiles.json") + is.NoErr(err) + is.Equal(path, filepath.Join(home, "profiles.json")) + + path, err = ExpandPath("~") + is.NoErr(err) + is.Equal(path, home) +} diff --git a/internal/oldconfig/oldconfig.go b/internal/oldconfig/oldconfig.go index fd0da96..2bb8857 100644 --- a/internal/oldconfig/oldconfig.go +++ b/internal/oldconfig/oldconfig.go @@ -11,7 +11,7 @@ type OldEntry struct { Value string `json:"value"` } -// OldConfig is the legacy config storage used for migration. +// OldConfig is the legacy array-based config storage for ~/.gitprofile. type OldConfig struct { Profiles map[string][]OldEntry `json:"profiles"` } diff --git a/vendor/github.com/mitchellh/go-homedir/LICENSE b/vendor/github.com/mitchellh/go-homedir/LICENSE deleted file mode 100644 index f9c841a..0000000 --- a/vendor/github.com/mitchellh/go-homedir/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2013 Mitchell Hashimoto - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. diff --git a/vendor/github.com/mitchellh/go-homedir/README.md b/vendor/github.com/mitchellh/go-homedir/README.md deleted file mode 100644 index d70706d..0000000 --- a/vendor/github.com/mitchellh/go-homedir/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# go-homedir - -This is a Go library for detecting the user's home directory without -the use of cgo, so the library can be used in cross-compilation environments. - -Usage is incredibly simple, just call `homedir.Dir()` to get the home directory -for a user, and `homedir.Expand()` to expand the `~` in a path to the home -directory. - -**Why not just use `os/user`?** The built-in `os/user` package requires -cgo on Darwin systems. This means that any Go code that uses that package -cannot cross compile. But 99% of the time the use for `os/user` is just to -retrieve the home directory, which we can do for the current user without -cgo. This library does that, enabling cross-compilation. diff --git a/vendor/github.com/mitchellh/go-homedir/homedir.go b/vendor/github.com/mitchellh/go-homedir/homedir.go deleted file mode 100644 index 2537853..0000000 --- a/vendor/github.com/mitchellh/go-homedir/homedir.go +++ /dev/null @@ -1,167 +0,0 @@ -package homedir - -import ( - "bytes" - "errors" - "os" - "os/exec" - "path/filepath" - "runtime" - "strconv" - "strings" - "sync" -) - -// DisableCache will disable caching of the home directory. Caching is enabled -// by default. -var DisableCache bool - -var homedirCache string -var cacheLock sync.RWMutex - -// Dir returns the home directory for the executing user. -// -// This uses an OS-specific method for discovering the home directory. -// An error is returned if a home directory cannot be detected. -func Dir() (string, error) { - if !DisableCache { - cacheLock.RLock() - cached := homedirCache - cacheLock.RUnlock() - if cached != "" { - return cached, nil - } - } - - cacheLock.Lock() - defer cacheLock.Unlock() - - var result string - var err error - if runtime.GOOS == "windows" { - result, err = dirWindows() - } else { - // Unix-like system, so just assume Unix - result, err = dirUnix() - } - - if err != nil { - return "", err - } - homedirCache = result - return result, nil -} - -// Expand expands the path to include the home directory if the path -// is prefixed with `~`. If it isn't prefixed with `~`, the path is -// returned as-is. -func Expand(path string) (string, error) { - if len(path) == 0 { - return path, nil - } - - if path[0] != '~' { - return path, nil - } - - if len(path) > 1 && path[1] != '/' && path[1] != '\\' { - return "", errors.New("cannot expand user-specific home dir") - } - - dir, err := Dir() - if err != nil { - return "", err - } - - return filepath.Join(dir, path[1:]), nil -} - -// Reset clears the cache, forcing the next call to Dir to re-detect -// the home directory. This generally never has to be called, but can be -// useful in tests if you're modifying the home directory via the HOME -// env var or something. -func Reset() { - cacheLock.Lock() - defer cacheLock.Unlock() - homedirCache = "" -} - -func dirUnix() (string, error) { - homeEnv := "HOME" - if runtime.GOOS == "plan9" { - // On plan9, env vars are lowercase. - homeEnv = "home" - } - - // First prefer the HOME environmental variable - if home := os.Getenv(homeEnv); home != "" { - return home, nil - } - - var stdout bytes.Buffer - - // If that fails, try OS specific commands - if runtime.GOOS == "darwin" { - cmd := exec.Command("sh", "-c", `dscl -q . -read /Users/"$(whoami)" NFSHomeDirectory | sed 's/^[^ ]*: //'`) - cmd.Stdout = &stdout - if err := cmd.Run(); err == nil { - result := strings.TrimSpace(stdout.String()) - if result != "" { - return result, nil - } - } - } else { - cmd := exec.Command("getent", "passwd", strconv.Itoa(os.Getuid())) - cmd.Stdout = &stdout - if err := cmd.Run(); err != nil { - // If the error is ErrNotFound, we ignore it. Otherwise, return it. - if err != exec.ErrNotFound { - return "", err - } - } else { - if passwd := strings.TrimSpace(stdout.String()); passwd != "" { - // username:password:uid:gid:gecos:home:shell - passwdParts := strings.SplitN(passwd, ":", 7) - if len(passwdParts) > 5 { - return passwdParts[5], nil - } - } - } - } - - // If all else fails, try the shell - stdout.Reset() - cmd := exec.Command("sh", "-c", "cd && pwd") - cmd.Stdout = &stdout - if err := cmd.Run(); err != nil { - return "", err - } - - result := strings.TrimSpace(stdout.String()) - if result == "" { - return "", errors.New("blank output when reading home directory") - } - - return result, nil -} - -func dirWindows() (string, error) { - // First prefer the HOME environmental variable - if home := os.Getenv("HOME"); home != "" { - return home, nil - } - - // Prefer standard environment variable USERPROFILE - if home := os.Getenv("USERPROFILE"); home != "" { - return home, nil - } - - drive := os.Getenv("HOMEDRIVE") - path := os.Getenv("HOMEPATH") - home := drive + path - if drive == "" || path == "" { - return "", errors.New("HOMEDRIVE, HOMEPATH, or USERPROFILE are blank") - } - - return home, nil -} diff --git a/vendor/modules.txt b/vendor/modules.txt index 1596eb6..b5d6824 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -81,9 +81,6 @@ github.com/matryer/moq/pkg/moq # github.com/mattn/go-runewidth v0.0.20 ## explicit; go 1.20 github.com/mattn/go-runewidth -# github.com/mitchellh/go-homedir v1.1.0 -## explicit -github.com/mitchellh/go-homedir # github.com/mitchellh/hashstructure/v2 v2.0.2 ## explicit; go 1.14 github.com/mitchellh/hashstructure/v2 From 9b56e29b81502a6728ca62b35130440b2ec0e2bd Mon Sep 17 00:00:00 2001 From: kuyacarlo <106532351+kuyacarlo@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:23:37 +0800 Subject: [PATCH 5/5] feat: add migrate command for legacy to XDG config Add an explicit `git-profile migrate` to copy ~/.gitprofile to the XDG path in map format without rewriting the legacy file in place. Co-authored-by: Cursor --- README.md | 9 ++++ cmd/migrate.go | 44 ++++++++++++++++ cmd/migrate_test.go | 50 ++++++++++++++++++ cmd/root.go | 9 +++- internal/config/config.go | 50 ++++++++++++++++++ internal/config/config_test.go | 96 ++++++++++++++++++++++++++++++++++ 6 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 cmd/migrate.go create mode 100644 cmd/migrate_test.go diff --git a/README.md b/README.md index 22f5343..171a099 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,15 @@ By default, Git Profile looks for profiles in this order: New installs create the XDG config file in the map-based format. Existing `~/.gitprofile` files stay in the legacy array-based format and are not rewritten in place. +To copy a legacy config to the XDG path in map format: + +```bash +git-profile migrate +``` + +The legacy file is kept. After migration, the XDG file takes priority. +Use `--force` to overwrite an existing XDG config file. + You can override the path with: ```bash diff --git a/cmd/migrate.go b/cmd/migrate.go new file mode 100644 index 0000000..01a9c23 --- /dev/null +++ b/cmd/migrate.go @@ -0,0 +1,44 @@ +package cmd + +import ( + "os" + + "github.com/spf13/cobra" + + "github.com/dotzero/git-profile/internal/config" + "github.com/dotzero/git-profile/internal/ui" +) + +// Migrate returns `migrate` command +func Migrate() *cobra.Command { + var force bool + + cmd := &cobra.Command{ + Use: "migrate", + Short: "Migrate ~/.gitprofile to the XDG config path", + Long: multiline( + "Copy ~/.gitprofile to $XDG_CONFIG_HOME/git-profile/config.json.", + "The destination uses the map-based format.", + "The legacy file is left in place. Once the XDG file exists, it takes priority.", + ), + Example: multiline( + `git-profile migrate`, + `git-profile migrate --force`, + ), + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, _ []string) { + src, dst, err := config.Migrate(force) + if err != nil { + ui.PrintErrln(cmd, ui.ErrorStyle, "%s", err) + os.Exit(1) + } + + ui.Println(cmd, ui.SuccessStyle, "Migrated `%s` to `%s`.", src, dst) + ui.Println(cmd, ui.DefaultStyle, "Legacy file kept at `%s`.", src) + }, + } + + cmd.Flags().BoolVar(&force, "force", false, "overwrite an existing XDG config file") + + return cmd +} diff --git a/cmd/migrate_test.go b/cmd/migrate_test.go new file mode 100644 index 0000000..946f81a --- /dev/null +++ b/cmd/migrate_test.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/matryer/is" + + "github.com/dotzero/git-profile/internal/config" +) + +func TestMigrateCommand(t *testing.T) { + is := is.New(t) + + home := t.TempDir() + xdgHome := filepath.Join(home, "xdg") + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", xdgHome) + + legacyPath := filepath.Join(home, ".gitprofile") + err := os.WriteFile(legacyPath, []byte(`{ + "profiles": { + "work": [ + {"key": "user.email", "value": "work@example.com"} + ] + } +}`), 0o644) + is.NoErr(err) + + var b bytes.Buffer + + cmd := Migrate() + cmd.SetOut(&b) + cmd.SetErr(&b) + err = cmd.Execute() + is.NoErr(err) + + xdgPath := filepath.Join(xdgHome, "git-profile", "config.json") + body, err := os.ReadFile(xdgPath) + is.NoErr(err) + + var decoded config.Config + + err = json.Unmarshal(body, &decoded) + is.NoErr(err) + is.Equal(decoded.Profiles["work"]["user.email"], "work@example.com") +} diff --git a/cmd/root.go b/cmd/root.go index 4360d82..5b7a2ee 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -61,7 +61,11 @@ func (c *Cmd) Execute() { } func (c *Cmd) init() { - cobra.OnInitialize(func() { + c.PersistentPreRun = func(cmd *cobra.Command, _ []string) { + if cmd.Name() == "migrate" { + return + } + filename, err := resolveConfigPath(c.filename) if err != nil { c.PrintErrln(err) @@ -75,7 +79,7 @@ func (c *Cmd) init() { } c.filename = filename - }) + } c.AddCommand( Add(c.config), @@ -85,6 +89,7 @@ func (c *Cmd) init() { List(c.config, c.git), Export(c.config), Import(c.config), + Migrate(), Use(c.config, c.git), Unuse(c.config, c.git), Version(c), diff --git a/internal/config/config.go b/internal/config/config.go index 57be23c..bd71920 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -62,6 +62,56 @@ func DefaultPath() (string, error) { return xdgPath, nil } +// XDGPath returns $XDG_CONFIG_HOME/git-profile/config.json. +func XDGPath() (string, error) { + return xdgConfigPath() +} + +// LegacyPath returns ~/.gitprofile. +func LegacyPath() (string, error) { + return legacyConfigPath() +} + +// Migrate copies ~/.gitprofile to the XDG path in map format. +// It does not remove the legacy file. If the XDG file already exists, +// set force to overwrite it. +func Migrate(force bool) (src string, dst string, err error) { + src, err = legacyConfigPath() + if err != nil { + return "", "", err + } + + dst, err = xdgConfigPath() + if err != nil { + return "", "", err + } + + if !fileExists(src) { + return "", "", fmt.Errorf("legacy config not found: %s", src) + } + + if fileExists(dst) && !force { + return "", "", fmt.Errorf("XDG config already exists: %s (use --force to overwrite)", dst) + } + + cfg := New() + + err = cfg.Load(src) + if err != nil { + return "", "", err + } + + // Always write the map format to the XDG path. + cfg.legacy = false + + err = cfg.Save(dst) + if err != nil { + return "", "", err + } + + return src, dst, nil +} + // ExpandPath expands a leading ~/ to the user home directory. func ExpandPath(path string) (string, error) { if path == "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7cef4b9..386f127 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -330,3 +330,99 @@ func TestExpandPath(t *testing.T) { is.NoErr(err) is.Equal(path, home) } + +func TestMigrate(t *testing.T) { + is := is.New(t) + + home := t.TempDir() + xdgHome := filepath.Join(home, "xdg") + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", xdgHome) + + legacyPath := filepath.Join(home, ".gitprofile") + xdgPath := filepath.Join(xdgHome, "git-profile", "config.json") + legacy := `{ + "profiles": { + "work": [ + { + "key": "user.email", + "value": "work@example.com" + }, + { + "key": "user.name", + "value": "John Doe" + } + ] + } +}` + + err := os.WriteFile(legacyPath, []byte(legacy), 0o644) + is.NoErr(err) + + src, dst, err := Migrate(false) + is.NoErr(err) + is.Equal(src, legacyPath) + is.Equal(dst, xdgPath) + + body, err := os.ReadFile(xdgPath) + is.NoErr(err) + + var decoded Config + + err = json.Unmarshal(body, &decoded) + is.NoErr(err) + is.Equal(decoded.Profiles["work"]["user.email"], "work@example.com") + is.Equal(decoded.Profiles["work"]["user.name"], "John Doe") + + // Legacy file must stay untouched. + legacyBody, err := os.ReadFile(legacyPath) + is.NoErr(err) + is.Equal(string(legacyBody), legacy) +} + +func TestMigrateRequiresLegacy(t *testing.T) { + is := is.New(t) + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg")) + + _, _, err := Migrate(false) + is.True(err != nil) + is.True(strings.Contains(err.Error(), "legacy config not found")) +} + +func TestMigrateRefusesExistingXDG(t *testing.T) { + is := is.New(t) + + home := t.TempDir() + xdgHome := filepath.Join(home, "xdg") + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", xdgHome) + + legacyPath := filepath.Join(home, ".gitprofile") + xdgPath := filepath.Join(xdgHome, "git-profile", "config.json") + + err := os.WriteFile(legacyPath, []byte(`{"profiles":{"work":[{"key":"user.email","value":"a@b.c"}]}}`), 0o644) + is.NoErr(err) + err = os.MkdirAll(filepath.Dir(xdgPath), 0o755) + is.NoErr(err) + err = os.WriteFile(xdgPath, []byte(`{"profiles":{}}`), 0o644) + is.NoErr(err) + + _, _, err = Migrate(false) + is.True(err != nil) + is.True(strings.Contains(err.Error(), "already exists")) + + _, _, err = Migrate(true) + is.NoErr(err) + + body, err := os.ReadFile(xdgPath) + is.NoErr(err) + + var decoded Config + + err = json.Unmarshal(body, &decoded) + is.NoErr(err) + is.Equal(decoded.Profiles["work"]["user.email"], "a@b.c") +}