detached 5b718b1

feat(remote): auto-migrate [client] config to named remote

On LoadRemotes, reads legacy gordon.toml [client] section and creates
a 'default' remote entry. Persists so migration runs only once.
commit
5b718b19f161b3daabe81810afc363a200a4a624
author
brice · · 2026-04-04 11:32 UTC
parent
026789a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +141 -2
M internal/adapters/in/cli/remote/config.go +60 -2
@@ -44,9 +44,11 @@ func LoadRemotes(path string) (*ClientConfig, error) {
4444 data, err := os.ReadFile(path)
4545 if err != nil {
4646 if os.IsNotExist(err) {
47- return &ClientConfig{
47+ config := &ClientConfig{
4848 Remotes: make(map[string]RemoteEntry),
49- }, nil
49+ }
50+ migrateClientConfig(config)
51+ return config, nil
5052 }
5153 return nil, fmt.Errorf("failed to read remotes: %w", err)
5254 }
@@ -60,9 +62,65 @@ func LoadRemotes(path string) (*ClientConfig, error) {
6062 config.Remotes = make(map[string]RemoteEntry)
6163 }
6264
65+ migrateClientConfig(&config)
66+
6367 return &config, nil
6468 }
6569
70+// migrateClientConfig reads the legacy gordon.toml [client] section and
71+// creates a "default" remote entry if one doesn't already exist.
72+func migrateClientConfig(config *ClientConfig) {
73+ gordonPath := defaultGordonTomlPath()
74+ data, err := os.ReadFile(gordonPath)
75+ if err != nil {
76+ return
77+ }
78+
79+ var legacy struct {
80+ Client struct {
81+ Mode string `toml:"mode"`
82+ Remote string `toml:"remote"`
83+ Token string `toml:"token"`
84+ TokenEnv string `toml:"token_env"`
85+ InsecureTLS bool `toml:"insecure_tls"`
86+ } `toml:"client"`
87+ }
88+ if err := toml.Unmarshal(data, &legacy); err != nil {
89+ return
90+ }
91+
92+ if legacy.Client.Mode != "remote" || legacy.Client.Remote == "" {
93+ return
94+ }
95+
96+ if _, exists := config.Remotes["default"]; exists {
97+ return
98+ }
99+
100+ fmt.Fprintf(os.Stderr, "Notice: migrated [client] config from gordon.toml to 'default' remote. The [client] section is deprecated; use 'gordon remotes' instead.\n")
101+
102+ config.Remotes["default"] = RemoteEntry{
103+ URL: legacy.Client.Remote,
104+ Token: legacy.Client.Token,
105+ TokenEnv: legacy.Client.TokenEnv,
106+ InsecureTLS: legacy.Client.InsecureTLS,
107+ }
108+
109+ if config.Active == "" {
110+ config.Active = "default"
111+ }
112+
113+ _ = SaveRemotes("", config)
114+}
115+
116+func defaultGordonTomlPath() string {
117+ configDir, err := os.UserConfigDir()
118+ if err != nil {
119+ configDir = os.Getenv("HOME")
120+ }
121+ return filepath.Join(configDir, "gordon", "gordon.toml")
122+}
123+
66124 // SaveRemotes saves the remotes configuration.
67125 func SaveRemotes(path string, config *ClientConfig) error {
68126 if path == "" {
@@ -44,9 +44,11 @@ func LoadRemotes(path string) (*ClientConfig, error) {
44 data, err := os.ReadFile(path)44 data, err := os.ReadFile(path)
45 if err != nil {45 if err != nil {
46 if os.IsNotExist(err) {46 if os.IsNotExist(err) {
47- return &ClientConfig{47+ config := &ClientConfig{
48 Remotes: make(map[string]RemoteEntry),48 Remotes: make(map[string]RemoteEntry),
49- }, nil49+ }
50+ migrateClientConfig(config)
51+ return config, nil
50 }52 }
51 return nil, fmt.Errorf("failed to read remotes: %w", err)53 return nil, fmt.Errorf("failed to read remotes: %w", err)
52 }54 }
@@ -60,9 +62,65 @@ func LoadRemotes(path string) (*ClientConfig, error) {
60 config.Remotes = make(map[string]RemoteEntry)62 config.Remotes = make(map[string]RemoteEntry)
61 }63 }
62 64
65+ migrateClientConfig(&config)
66+
63 return &config, nil67 return &config, nil
64 }68 }
65 69
70+// migrateClientConfig reads the legacy gordon.toml [client] section and
71+// creates a "default" remote entry if one doesn't already exist.
72+func migrateClientConfig(config *ClientConfig) {
73+ gordonPath := defaultGordonTomlPath()
74+ data, err := os.ReadFile(gordonPath)
75+ if err != nil {
76+ return
77+ }
78+
79+ var legacy struct {
80+ Client struct {
81+ Mode string `toml:"mode"`
82+ Remote string `toml:"remote"`
83+ Token string `toml:"token"`
84+ TokenEnv string `toml:"token_env"`
85+ InsecureTLS bool `toml:"insecure_tls"`
86+ } `toml:"client"`
87+ }
88+ if err := toml.Unmarshal(data, &legacy); err != nil {
89+ return
90+ }
91+
92+ if legacy.Client.Mode != "remote" || legacy.Client.Remote == "" {
93+ return
94+ }
95+
96+ if _, exists := config.Remotes["default"]; exists {
97+ return
98+ }
99+
100+ fmt.Fprintf(os.Stderr, "Notice: migrated [client] config from gordon.toml to 'default' remote. The [client] section is deprecated; use 'gordon remotes' instead.\n")
101+
102+ config.Remotes["default"] = RemoteEntry{
103+ URL: legacy.Client.Remote,
104+ Token: legacy.Client.Token,
105+ TokenEnv: legacy.Client.TokenEnv,
106+ InsecureTLS: legacy.Client.InsecureTLS,
107+ }
108+
109+ if config.Active == "" {
110+ config.Active = "default"
111+ }
112+
113+ _ = SaveRemotes("", config)
114+}
115+
116+func defaultGordonTomlPath() string {
117+ configDir, err := os.UserConfigDir()
118+ if err != nil {
119+ configDir = os.Getenv("HOME")
120+ }
121+ return filepath.Join(configDir, "gordon", "gordon.toml")
122+}
123+
66 // SaveRemotes saves the remotes configuration.124 // SaveRemotes saves the remotes configuration.
67 func SaveRemotes(path string, config *ClientConfig) error {125 func SaveRemotes(path string, config *ClientConfig) error {
68 if path == "" {126 if path == "" {
A internal/adapters/in/cli/remote/migrate_test.go +81 -0
@@ -0,0 +1,81 @@
1+package remote
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestMigrateClientConfig(t *testing.T) {
13+ tmpDir := t.TempDir()
14+ t.Setenv("XDG_CONFIG_HOME", tmpDir)
15+
16+ gordonToml := filepath.Join(tmpDir, "gordon", "gordon.toml")
17+ require.NoError(t, os.MkdirAll(filepath.Dir(gordonToml), 0o700))
18+ require.NoError(t, os.WriteFile(gordonToml, []byte(`
19+[client]
20+mode = "remote"
21+remote = "https://old.example.com"
22+token = "oldtok"
23+insecure_tls = true
24+`), 0o600))
25+
26+ config, err := LoadRemotes("")
27+ require.NoError(t, err)
28+
29+ entry, ok := config.Remotes["default"]
30+ require.True(t, ok, "expected 'default' remote entry from migration")
31+ assert.Equal(t, "https://old.example.com", entry.URL)
32+ assert.Equal(t, "oldtok", entry.Token)
33+ assert.True(t, entry.InsecureTLS)
34+ assert.Equal(t, "default", config.Active)
35+}
36+
37+func TestMigrateClientConfig_NoOverwrite(t *testing.T) {
38+ tmpDir := t.TempDir()
39+ t.Setenv("XDG_CONFIG_HOME", tmpDir)
40+
41+ gordonToml := filepath.Join(tmpDir, "gordon", "gordon.toml")
42+ require.NoError(t, os.MkdirAll(filepath.Dir(gordonToml), 0o700))
43+ require.NoError(t, os.WriteFile(gordonToml, []byte(`
44+[client]
45+mode = "remote"
46+remote = "https://old.example.com"
47+token = "oldtok"
48+`), 0o600))
49+
50+ existing := &ClientConfig{
51+ Active: "prod",
52+ Remotes: map[string]RemoteEntry{
53+ "prod": {URL: "https://prod.example.com"},
54+ "default": {URL: "https://existing-default.example.com"},
55+ },
56+ }
57+ require.NoError(t, SaveRemotes("", existing))
58+
59+ config, err := LoadRemotes("")
60+ require.NoError(t, err)
61+ assert.Equal(t, "https://existing-default.example.com", config.Remotes["default"].URL)
62+ assert.Equal(t, "prod", config.Active)
63+}
64+
65+func TestMigrateClientConfig_SkipsLocalMode(t *testing.T) {
66+ tmpDir := t.TempDir()
67+ t.Setenv("XDG_CONFIG_HOME", tmpDir)
68+
69+ gordonToml := filepath.Join(tmpDir, "gordon", "gordon.toml")
70+ require.NoError(t, os.MkdirAll(filepath.Dir(gordonToml), 0o700))
71+ require.NoError(t, os.WriteFile(gordonToml, []byte(`
72+[client]
73+mode = "local"
74+remote = "https://ignored.example.com"
75+`), 0o600))
76+
77+ config, err := LoadRemotes("")
78+ require.NoError(t, err)
79+ _, ok := config.Remotes["default"]
80+ assert.False(t, ok)
81+}
@@ -0,0 +1,81 @@
1+package remote
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestMigrateClientConfig(t *testing.T) {
13+ tmpDir := t.TempDir()
14+ t.Setenv("XDG_CONFIG_HOME", tmpDir)
15+
16+ gordonToml := filepath.Join(tmpDir, "gordon", "gordon.toml")
17+ require.NoError(t, os.MkdirAll(filepath.Dir(gordonToml), 0o700))
18+ require.NoError(t, os.WriteFile(gordonToml, []byte(`
19+[client]
20+mode = "remote"
21+remote = "https://old.example.com"
22+token = "oldtok"
23+insecure_tls = true
24+`), 0o600))
25+
26+ config, err := LoadRemotes("")
27+ require.NoError(t, err)
28+
29+ entry, ok := config.Remotes["default"]
30+ require.True(t, ok, "expected 'default' remote entry from migration")
31+ assert.Equal(t, "https://old.example.com", entry.URL)
32+ assert.Equal(t, "oldtok", entry.Token)
33+ assert.True(t, entry.InsecureTLS)
34+ assert.Equal(t, "default", config.Active)
35+}
36+
37+func TestMigrateClientConfig_NoOverwrite(t *testing.T) {
38+ tmpDir := t.TempDir()
39+ t.Setenv("XDG_CONFIG_HOME", tmpDir)
40+
41+ gordonToml := filepath.Join(tmpDir, "gordon", "gordon.toml")
42+ require.NoError(t, os.MkdirAll(filepath.Dir(gordonToml), 0o700))
43+ require.NoError(t, os.WriteFile(gordonToml, []byte(`
44+[client]
45+mode = "remote"
46+remote = "https://old.example.com"
47+token = "oldtok"
48+`), 0o600))
49+
50+ existing := &ClientConfig{
51+ Active: "prod",
52+ Remotes: map[string]RemoteEntry{
53+ "prod": {URL: "https://prod.example.com"},
54+ "default": {URL: "https://existing-default.example.com"},
55+ },
56+ }
57+ require.NoError(t, SaveRemotes("", existing))
58+
59+ config, err := LoadRemotes("")
60+ require.NoError(t, err)
61+ assert.Equal(t, "https://existing-default.example.com", config.Remotes["default"].URL)
62+ assert.Equal(t, "prod", config.Active)
63+}
64+
65+func TestMigrateClientConfig_SkipsLocalMode(t *testing.T) {
66+ tmpDir := t.TempDir()
67+ t.Setenv("XDG_CONFIG_HOME", tmpDir)
68+
69+ gordonToml := filepath.Join(tmpDir, "gordon", "gordon.toml")
70+ require.NoError(t, os.MkdirAll(filepath.Dir(gordonToml), 0o700))
71+ require.NoError(t, os.WriteFile(gordonToml, []byte(`
72+[client]
73+mode = "local"
74+remote = "https://ignored.example.com"
75+`), 0o600))
76+
77+ config, err := LoadRemotes("")
78+ require.NoError(t, err)
79+ _, ok := config.Remotes["default"]
80+ assert.False(t, ok)
81+}

Keyboard shortcuts

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

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

Branches 7

Tags 106

↑↓ moveenter openesc close