v2.30.0-pre3 63e7f3a

config.go RawHistoryFolder

1package remote
2
3import (
4	"fmt"
5	"os"
6	"path/filepath"
7	"strconv"
8
9	"github.com/pelletier/go-toml/v2"
10
11	"github.com/bnema/gordon/internal/domain"
12)
13
14// ClientConfig represents the client-mode configuration.
15type ClientConfig struct {
16	Client  ClientSettings         `toml:"client"`
17	Remotes map[string]RemoteEntry `toml:"remotes"`
18	Active  string                 `toml:"active"` // Active remote name
19}
20
21// ClientSettings represents the [client] section.
22type ClientSettings struct {
23	Mode        string `toml:"mode"`         // "local" (default) or "remote"
24	Remote      string `toml:"remote"`       // Remote Gordon URL
25	Token       string `toml:"token"`        // Auth token
26	TokenEnv    string `toml:"token_env"`    // Env var name for token
27	InsecureTLS bool   `toml:"insecure_tls"` // Skip TLS verification for remote admin API
28}
29
30// RemoteEntry represents a saved remote in [remotes.*].
31type RemoteEntry struct {
32	URL         string `toml:"url"`
33	Token       string `toml:"token,omitempty"`
34	TokenEnv    string `toml:"token_env,omitempty"`
35	InsecureTLS bool   `toml:"insecure_tls,omitempty"`
36}
37
38// DefaultClientConfigPath returns the default client config path.
39func DefaultClientConfigPath() string {
40	configDir, err := os.UserConfigDir()
41	if err != nil {
42		configDir = os.Getenv("HOME")
43	}
44	return filepath.Join(configDir, "gordon", "gordon.toml")
45}
46
47// DefaultRemotesPath returns the default remotes config path.
48func DefaultRemotesPath() string {
49	configDir, err := os.UserConfigDir()
50	if err != nil {
51		configDir = os.Getenv("HOME")
52	}
53	return filepath.Join(configDir, "gordon", "remotes.toml")
54}
55
56// LoadClientConfig loads the client configuration.
57func LoadClientConfig(path string) (*ClientConfig, error) {
58	if path == "" {
59		path = DefaultClientConfigPath()
60	}
61
62	data, err := os.ReadFile(path)
63	if err != nil {
64		if os.IsNotExist(err) {
65			// Return default config
66			return &ClientConfig{
67				Client: ClientSettings{
68					Mode: "local",
69				},
70				Remotes: make(map[string]RemoteEntry),
71			}, nil
72		}
73		return nil, fmt.Errorf("failed to read config: %w", err)
74	}
75
76	var config ClientConfig
77	if err := toml.Unmarshal(data, &config); err != nil {
78		return nil, fmt.Errorf("failed to parse config: %w", err)
79	}
80
81	if config.Remotes == nil {
82		config.Remotes = make(map[string]RemoteEntry)
83	}
84
85	return &config, nil
86}
87
88// LoadRemotes loads the remotes configuration.
89func LoadRemotes(path string) (*ClientConfig, error) {
90	if path == "" {
91		path = DefaultRemotesPath()
92	}
93
94	data, err := os.ReadFile(path)
95	if err != nil {
96		if os.IsNotExist(err) {
97			return &ClientConfig{
98				Remotes: make(map[string]RemoteEntry),
99			}, nil
100		}
101		return nil, fmt.Errorf("failed to read remotes: %w", err)
102	}
103
104	var config ClientConfig
105	if err := toml.Unmarshal(data, &config); err != nil {
106		return nil, fmt.Errorf("failed to parse remotes: %w", err)
107	}
108
109	if config.Remotes == nil {
110		config.Remotes = make(map[string]RemoteEntry)
111	}
112
113	return &config, nil
114}
115
116// SaveRemotes saves the remotes configuration.
117func SaveRemotes(path string, config *ClientConfig) error {
118	if path == "" {
119		path = DefaultRemotesPath()
120	}
121
122	// Ensure directory exists
123	dir := filepath.Dir(path)
124	if err := os.MkdirAll(dir, 0700); err != nil {
125		return fmt.Errorf("failed to create config directory: %w", err)
126	}
127
128	data, err := toml.Marshal(config)
129	if err != nil {
130		return fmt.Errorf("failed to marshal remotes: %w", err)
131	}
132
133	if err := os.WriteFile(path, data, 0600); err != nil {
134		return fmt.Errorf("failed to write remotes: %w", err)
135	}
136
137	return nil
138}
139
140// ResolveRemote resolves the remote URL and token from configuration.
141// Precedence: flag > env > config > active remote
142func ResolveRemote(flagRemote, flagToken string, flagInsecure bool) (url, token string, insecureTLS, isRemote bool) {
143	url, token, insecureTLS, _, isRemote = ResolveRemoteFull(flagRemote, flagToken, flagInsecure)
144	return url, token, insecureTLS, isRemote
145}
146
147// ResolveRemoteFull resolves the remote URL, token, and named remote from configuration.
148// It also returns the remote name for use in token persistence callbacks.
149// Precedence: flag > env > config > active remote
150func ResolveRemoteFull(flagRemote, flagToken string, flagInsecure bool) (url, token string, insecureTLS bool, remoteName string, isRemote bool) {
151	config, _ := LoadClientConfig("")
152	remotes, _ := LoadRemotes("")
153
154	var name string
155	url, name, isRemote = resolveRemoteURL(flagRemote, config, remotes)
156	if isRemote {
157		// resolveToken looks up remotes.Active internally; name == remotes.Active here
158		// because resolveRemoteURL only sets name when it finds the active remote entry.
159		token = resolveToken(flagToken, config, remotes)
160		insecureTLS = resolveInsecureTLS(flagInsecure, config, remotes, name)
161		remoteName = name
162	}
163
164	return url, token, insecureTLS, remoteName, isRemote
165}
166
167// resolveRemoteURL resolves the remote URL from various sources.
168func resolveRemoteURL(flagRemote string, config *ClientConfig, remotes *ClientConfig) (url, remoteName string, isRemote bool) {
169	// 1. Check flag
170	if flagRemote != "" {
171		return flagRemote, "", true
172	}
173
174	// 2. Check environment variable
175	if envRemote := os.Getenv("GORDON_REMOTE"); envRemote != "" {
176		return envRemote, "", true
177	}
178
179	// 3. Check client config
180	if config != nil && config.Client.Mode == "remote" && config.Client.Remote != "" {
181		return config.Client.Remote, "", true
182	}
183
184	// 4. Check active remote
185	if remotes != nil && remotes.Active != "" {
186		if remote, ok := remotes.Remotes[remotes.Active]; ok {
187			return remote.URL, remotes.Active, true
188		}
189	}
190
191	return "", "", false
192}
193
194// resolveToken resolves the authentication token from various sources.
195func resolveToken(flagToken string, config *ClientConfig, remotes *ClientConfig) string {
196	activeRemote := activeRemoteEntry(remotes)
197
198	// 1. Flag token takes precedence
199	if flagToken != "" {
200		return flagToken
201	}
202
203	// 2. Environment variable
204	if envToken := os.Getenv("GORDON_TOKEN"); envToken != "" {
205		return envToken
206	}
207
208	// 3. Config token
209	if config != nil {
210		if config.Client.Token != "" {
211			return config.Client.Token
212		}
213		if config.Client.TokenEnv != "" {
214			if envToken := os.Getenv(config.Client.TokenEnv); envToken != "" {
215				return envToken
216			}
217		}
218	}
219
220	// 4. Try pass store for active remote
221	if remotes != nil && remotes.Active != "" && passAvailable() {
222		if token, err := passReadToken(remotes.Active); err == nil && token != "" {
223			return token
224		}
225	}
226
227	// 5. Active remote token
228	if token := resolveRemoteEntryToken(activeRemote); token != "" {
229		return token
230	}
231
232	return ""
233}
234
235func activeRemoteEntry(remotes *ClientConfig) *RemoteEntry {
236	if remotes == nil || remotes.Active == "" {
237		return nil
238	}
239
240	remote, ok := remotes.Remotes[remotes.Active]
241	if !ok {
242		return nil
243	}
244
245	return &remote
246}
247
248func resolveRemoteEntryToken(remote *RemoteEntry) string {
249	if remote == nil {
250		return ""
251	}
252	if remote.Token != "" {
253		return remote.Token
254	}
255	if remote.TokenEnv != "" {
256		return os.Getenv(remote.TokenEnv)
257	}
258	return ""
259}
260
261// ResolveTokenForRemote resolves the token for a named remote.
262// Precedence: TOML token field > token_env > pass store.
263func ResolveTokenForRemote(name string, entry RemoteEntry) string {
264	if token := resolveRemoteEntryToken(&entry); token != "" {
265		return token
266	}
267	if passAvailable() {
268		if token, err := passReadToken(name); err == nil && token != "" {
269			return token
270		}
271	}
272	return ""
273}
274
275// ResolveInsecureTLSForRemote resolves insecure TLS behavior for a named remote.
276// Precedence: flag > env > client config > specific remote config.
277func ResolveInsecureTLSForRemote(flagInsecure bool, remoteName string) bool {
278	config, _ := LoadClientConfig("")
279	remotes, _ := LoadRemotes("")
280	return resolveInsecureTLS(flagInsecure, config, remotes, remoteName)
281}
282
283func resolveInsecureTLS(flagInsecure bool, config *ClientConfig, remotes *ClientConfig, remoteName string) bool {
284	if flagInsecure {
285		return true
286	}
287
288	if env := os.Getenv("GORDON_INSECURE"); env != "" {
289		if value, err := strconv.ParseBool(env); err == nil {
290			return value
291		}
292		fmt.Fprintf(os.Stderr, "WARNING: invalid GORDON_INSECURE value %q, ignoring\n", env)
293	}
294
295	if config != nil && config.Client.InsecureTLS {
296		return true
297	}
298
299	if remoteName != "" && remotes != nil {
300		if remote, ok := remotes.Remotes[remoteName]; ok && remote.InsecureTLS {
301			return true
302		}
303	}
304
305	return false
306}
307
308// AddRemote adds a new remote to the remotes configuration.
309func AddRemote(name, url, token string, insecureTLS bool) error {
310	config, err := LoadRemotes("")
311	if err != nil {
312		return err
313	}
314
315	config.Remotes[name] = RemoteEntry{
316		URL:         url,
317		Token:       token,
318		InsecureTLS: insecureTLS,
319	}
320
321	return SaveRemotes("", config)
322}
323
324// RemoveRemote removes a remote from the remotes configuration.
325func RemoveRemote(name string) error {
326	config, err := LoadRemotes("")
327	if err != nil {
328		return err
329	}
330
331	delete(config.Remotes, name)
332
333	// Clear active if it was the removed remote
334	if config.Active == name {
335		config.Active = ""
336	}
337
338	return SaveRemotes("", config)
339}
340
341// SetActiveRemote sets the active remote.
342func SetActiveRemote(name string) error {
343	config, err := LoadRemotes("")
344	if err != nil {
345		return err
346	}
347
348	// Verify remote exists
349	if _, ok := config.Remotes[name]; !ok {
350		return fmt.Errorf("%w: %s", domain.ErrRemoteNotFound, name)
351	}
352
353	config.Active = name
354
355	return SaveRemotes("", config)
356}
357
358// ClearRemoteToken removes the token and token_env fields for a named remote
359// from the remotes TOML config, and deletes the pass store entry if present.
360func ClearRemoteToken(name string) error {
361	// Delete from pass (best-effort, no error if pass unavailable)
362	_ = passDeleteToken(name)
363
364	// Clear TOML fields
365	config, err := LoadRemotes("")
366	if err != nil {
367		return err
368	}
369
370	entry, ok := config.Remotes[name]
371	if !ok {
372		return fmt.Errorf("%w: %s", domain.ErrRemoteNotFound, name)
373	}
374
375	entry.Token = ""
376	entry.TokenEnv = ""
377	config.Remotes[name] = entry
378
379	return SaveRemotes("", config)
380}
381
382// ListRemotes returns all saved remotes.
383func ListRemotes() (map[string]RemoteEntry, string, error) {
384	config, err := LoadRemotes("")
385	if err != nil {
386		return nil, "", err
387	}
388
389	return config.Remotes, config.Active, nil
390}

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