diff --git a/README.md b/README.md index 4417a4c..171a099 100644 --- a/README.md +++ b/README.md @@ -180,13 +180,24 @@ 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: + +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. + +To copy a legacy config to the XDG path in map format: ```bash -~/.gitprofile +git-profile migrate ``` -You can override it with: +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 git-profile --config /path/to/file add work user.email work@example.com 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..5a8cb13 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,21 +19,32 @@ 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") - var entries []config.Entry + var entries config.Entry 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 _, entry := range entries { - cfg.Store(profile, entry.Key, entry.Value) + 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 f4b25f8..6a5bd22 100644 --- a/cmd/import_test.go +++ b/cmd/import_test.go @@ -8,23 +8,63 @@ 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") - var b bytes.Buffer + stored[key] = value + }, + } - cmd := Import(cfg) + var b bytes.Buffer - cmd.SetOut(&b) - cmd.SetArgs([]string{"profile", `[{"key":"user.email","value":"work@example.com"}]`}) - err := cmd.Execute() + cmd := Import(cfg) + cmd.SetOut(&b) + cmd.SetArgs([]string{"profile", `{"user.email": "work@example.com", "core.autocrlf": "input"}`}) + err := cmd.Execute() - is.NoErr(err) - is.Equal(trim(b.String()), "Successfully imported `profile` profile.") + 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", + }) + }) + + t.Run("array format", func(t *testing.T) { + is := is.New(t) + + 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/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..be1bd2e 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,8 +25,16 @@ 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) + + 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 bd08990..8514445 100644 --- a/cmd/list_test.go +++ b/cmd/list_test.go @@ -19,9 +19,10 @@ 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", + "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/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/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/root.go b/cmd/root.go index 00f69a9..5b7a2ee 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" @@ -62,8 +61,12 @@ func (c *Cmd) Execute() { } func (c *Cmd) init() { - cobra.OnInitialize(func() { - filename, err := homedir.Expand(c.filename) + c.PersistentPreRun = func(cmd *cobra.Command, _ []string) { + if cmd.Name() == "migrate" { + return + } + + filename, err := resolveConfigPath(c.filename) if err != nil { c.PrintErrln(err) os.Exit(1) @@ -76,7 +79,7 @@ func (c *Cmd) init() { } c.filename = filename - }) + } c.AddCommand( Add(c.config), @@ -86,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), @@ -94,7 +98,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/cmd/unuse.go b/cmd/unuse.go index 19c0154..ee88f56 100644 --- a/cmd/unuse.go +++ b/cmd/unuse.go @@ -59,8 +59,8 @@ func profileUnapply(cmd *cobra.Command, cfg storage, v vcs, profile string) { os.Exit(0) } - for _, entry := range entries { - err := v.Unset(entry.Key) + 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) os.Exit(1) diff --git a/cmd/unuse_test.go b/cmd/unuse_test.go index 31cc0b2..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" @@ -17,9 +18,10 @@ 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", + "core.autocrlf": "input", }, true }, } @@ -46,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 a2536c9..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 _, entry := range entries { - err := v.Set(entry.Key, entry.Value) + 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 a57278a..8b9f1f5 100644 --- a/cmd/use_test.go +++ b/cmd/use_test.go @@ -18,18 +18,21 @@ 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", + "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) { diff --git a/go.mod b/go.mod index 629dbd5..c6eddbb 100644 --- a/go.mod +++ b/go.mod @@ -4,20 +4,19 @@ 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 ) 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/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 da98952..bd71920 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,26 +2,136 @@ 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 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"` + + // 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 func New() *Config { return &Config{ - Profiles: make(map[string][]Entry), + Profiles: make(map[string]Entry), + } +} + +// 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 +} + +// 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 == "" { + 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 @@ -30,7 +140,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 +160,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] - - for _, e := range c.Profiles[profile] { - if e.Key != value { - entries = append(entries, e) - } - } - - delete(c.Profiles, profile) + delete(c.Profiles[profile], key) - if len(entries) > 0 { - c.Profiles[profile] = entries + if len(c.Profiles[profile]) == 0 { + delete(c.Profiles, profile) } return true @@ -87,11 +189,23 @@ 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 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 @@ -103,16 +217,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 } - return json.Unmarshal(body, c) + if c.Profiles == nil { + c.Profiles = make(map[string]Entry) + } + + return nil +} + +func (c *Config) saveLegacy(filename string) error { + oldCfg := oldconfig.New() + + 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 { + return "", err + } + + 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 + } + } + + 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 b4fe812..386f127 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" @@ -10,10 +14,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 +34,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 +61,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 +73,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 +85,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 +98,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", }, }, }, @@ -116,3 +120,309 @@ 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) +} + +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") +} diff --git a/internal/oldconfig/oldconfig.go b/internal/oldconfig/oldconfig.go new file mode 100644 index 0000000..2bb8857 --- /dev/null +++ b/internal/oldconfig/oldconfig.go @@ -0,0 +1,56 @@ +package oldconfig + +import ( + "encoding/json" + "os" +) + +// OldEntry is a single key/value pair in the legacy config file format. +type OldEntry struct { + Key string `json:"key"` + Value string `json:"value"` +} + +// OldConfig is the legacy array-based config storage for ~/.gitprofile. +type OldConfig struct { + Profiles map[string][]OldEntry `json:"profiles"` +} + +// New initializes and returns a new OldConfig. +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) +} 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