v3.3.0 b164fe9

config.go RawHistoryFolder

1package remote
2
3import (
4	"fmt"
5	"os"
6	"path/filepath"
7	"strconv"
8	"strings"
9
10	"github.com/pelletier/go-toml/v2"
11
12	"github.com/bnema/gordon/internal/domain"
13)
14
15// ClientConfig represents the client-mode configuration.
16type ClientConfig struct {
17	Remotes map[string]RemoteEntry `toml:"remotes"`
18	Active  string                 `toml:"active"` // Active remote name
19}
20
21// RemoteEntry represents a saved remote in [remotes.*].
22type RemoteEntry struct {
23	URL         string `toml:"url"`
24	Token       string `toml:"token,omitempty"`
25	TokenEnv    string `toml:"token_env,omitempty"`
26	InsecureTLS bool   `toml:"insecure_tls,omitempty"`
27}
28
29// DefaultRemotesPath returns the default remotes config path.
30func DefaultRemotesPath() string {
31	configDir, err := os.UserConfigDir()
32	if err != nil {
33		configDir = os.Getenv("HOME")
34	}
35	return filepath.Join(configDir, "gordon", "remotes.toml")
36}
37
38// LoadRemotes loads the remotes configuration.
39func LoadRemotes(path string) (*ClientConfig, error) {
40	if path == "" {
41		path = DefaultRemotesPath()
42	}
43
44	data, err := os.ReadFile(path)
45	if err != nil {
46		if os.IsNotExist(err) {
47			config := &ClientConfig{
48				Remotes: make(map[string]RemoteEntry),
49			}
50			migrateClientConfig(config)
51			return config, nil
52		}
53		return nil, fmt.Errorf("failed to read remotes: %w", err)
54	}
55
56	var config ClientConfig
57	if err := toml.Unmarshal(data, &config); err != nil {
58		return nil, fmt.Errorf("failed to parse remotes: %w", err)
59	}
60
61	if config.Remotes == nil {
62		config.Remotes = make(map[string]RemoteEntry)
63	}
64
65	migrateClientConfig(&config)
66
67	return &config, nil
68}
69
70// migrateClientConfig reads the legacy gordon.toml [client] section and
71// creates a "default" remote entry if one doesn't already exist.
72func 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	if _, err := fmt.Fprintf(os.Stderr, "Notice: migrated [client] config from gordon.toml to 'default' remote. The [client] section is deprecated; use 'gordon remotes' instead.\n"); err != nil {
101		_, _ = os.Stderr.WriteString("Warning: failed to write remote migration notice\n")
102	}
103
104	config.Remotes["default"] = RemoteEntry{
105		URL:         legacy.Client.Remote,
106		Token:       legacy.Client.Token,
107		TokenEnv:    legacy.Client.TokenEnv,
108		InsecureTLS: legacy.Client.InsecureTLS,
109	}
110
111	if config.Active == "" {
112		config.Active = "default"
113	}
114
115	if err := SaveRemotes("", config); err != nil {
116		if _, writeErr := fmt.Fprintf(os.Stderr, "Warning: failed to save migrated remotes: %v\n", err); writeErr != nil {
117			_, _ = os.Stderr.WriteString("Warning: failed to write remote migration warning\n")
118		}
119	}
120}
121
122func defaultGordonTomlPath() string {
123	configDir, err := os.UserConfigDir()
124	if err != nil {
125		configDir = os.Getenv("HOME")
126	}
127	return filepath.Join(configDir, "gordon", "gordon.toml")
128}
129
130// SaveRemotes saves the remotes configuration.
131func SaveRemotes(path string, config *ClientConfig) error {
132	if path == "" {
133		path = DefaultRemotesPath()
134	}
135
136	// Ensure directory exists
137	dir := filepath.Dir(path)
138	if err := os.MkdirAll(dir, 0700); err != nil {
139		return fmt.Errorf("failed to create config directory: %w", err)
140	}
141
142	data, err := toml.Marshal(config)
143	if err != nil {
144		return fmt.Errorf("failed to marshal remotes: %w", err)
145	}
146
147	if err := os.WriteFile(path, data, 0600); err != nil {
148		return fmt.Errorf("failed to write remotes: %w", err)
149	}
150
151	return nil
152}
153
154func resolveRemoteEntryToken(remote *RemoteEntry) string {
155	if remote == nil {
156		return ""
157	}
158	if remote.Token != "" {
159		return remote.Token
160	}
161	if remote.TokenEnv != "" {
162		return os.Getenv(remote.TokenEnv)
163	}
164	return ""
165}
166
167// ResolveTokenForRemote resolves the token for a named remote.
168// Precedence: TOML token field > token_env > pass store.
169func ResolveTokenForRemote(name string, entry RemoteEntry) string {
170	if token := resolveRemoteEntryToken(&entry); token != "" {
171		return token
172	}
173	if passAvailable() {
174		if token, err := passReadToken(name); err == nil && token != "" {
175			return token
176		}
177	}
178	return ""
179}
180
181// ResolvedRemote holds the fully resolved remote target.
182// Name is empty for ad-hoc URLs passed directly via flag or env.
183type ResolvedRemote struct {
184	Name        string
185	URL         string
186	Token       string
187	InsecureTLS bool
188}
189
190// DisplayName returns the remote name, or the URL if unnamed (ad-hoc).
191func (r *ResolvedRemote) DisplayName() string {
192	if r.Name != "" {
193		return r.Name
194	}
195	return r.URL
196}
197
198// resolveTokenForTarget resolves the authentication token.
199// Precedence: flag > GORDON_TOKEN env > stored token for named remote.
200func resolveTokenForTarget(flagToken, name string, remotes *ClientConfig) string {
201	if flagToken != "" {
202		return flagToken
203	}
204	if envToken := os.Getenv("GORDON_TOKEN"); envToken != "" {
205		return envToken
206	}
207	if name != "" && remotes != nil {
208		if entry, ok := remotes.Remotes[name]; ok {
209			return ResolveTokenForRemote(name, entry)
210		}
211	}
212	return ""
213}
214
215// resolveInsecureForTarget resolves InsecureTLS setting.
216// Precedence: flag > GORDON_INSECURE env > remote entry field.
217func resolveInsecureForTarget(flagInsecure bool, name string, remotes *ClientConfig) bool {
218	if flagInsecure {
219		return true
220	}
221	if env := os.Getenv("GORDON_INSECURE"); env != "" {
222		if v, err := strconv.ParseBool(env); err == nil && v {
223			return true
224		}
225	}
226	if name != "" && remotes != nil {
227		if entry, ok := remotes.Remotes[name]; ok {
228			return entry.InsecureTLS
229		}
230	}
231	return false
232}
233
234// Resolve resolves the remote target from flags, environment, and config.
235// Returns nil, false when no remote is configured (local mode).
236//
237// URL precedence: flag > GORDON_REMOTE env > active remote.
238// The flag and env values are treated as a saved remote name first;
239// if no match and the value starts with http:// or https://, used as ad-hoc URL.
240//
241// Token precedence: flag > GORDON_TOKEN env > named remote (pass > TOML token > token_env).
242// InsecureTLS precedence: flag > GORDON_INSECURE env > remote entry field.
243func Resolve(flagRemote, flagToken string, flagInsecure bool) (*ResolvedRemote, bool) {
244	resolved, found, _ := ResolveStrict(flagRemote, flagToken, flagInsecure)
245	return resolved, found
246}
247
248// ResolveStrict resolves the remote target like Resolve, but returns an error
249// when an explicit remote selector is invalid instead of silently falling back
250// to local mode.
251func ResolveStrict(flagRemote, flagToken string, flagInsecure bool) (*ResolvedRemote, bool, error) {
252	remotes, err := LoadRemotes("")
253	if err != nil {
254		return nil, false, fmt.Errorf("loading remotes: %w", err)
255	}
256
257	name, url, found := resolveTarget(flagRemote, remotes)
258	if !found && flagRemote != "" {
259		return nil, false, fmt.Errorf("remote %q: %w", flagRemote, domain.ErrRemoteNotFound)
260	}
261	if !found {
262		if envRemote := os.Getenv("GORDON_REMOTE"); envRemote != "" {
263			name, url, found = resolveTarget(envRemote, remotes)
264			if !found {
265				return nil, false, fmt.Errorf("remote %q from GORDON_REMOTE: %w", envRemote, domain.ErrRemoteNotFound)
266			}
267		}
268	}
269	if !found && remotes != nil && remotes.Active != "" {
270		if entry, ok := remotes.Remotes[remotes.Active]; ok {
271			name = remotes.Active
272			url = entry.URL
273			found = true
274		}
275	}
276	if !found {
277		return nil, false, nil
278	}
279
280	return &ResolvedRemote{
281		Name:        name,
282		URL:         url,
283		Token:       resolveTokenForTarget(flagToken, name, remotes),
284		InsecureTLS: resolveInsecureForTarget(flagInsecure, name, remotes),
285	}, true, nil
286}
287
288// resolveTarget checks if value is a saved remote name or an ad-hoc URL.
289func resolveTarget(value string, remotes *ClientConfig) (name, url string, found bool) {
290	if value == "" {
291		return "", "", false
292	}
293	if remotes != nil {
294		if entry, ok := remotes.Remotes[value]; ok {
295			return value, entry.URL, true
296		}
297	}
298	if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
299		return "", value, true
300	}
301	return "", "", false
302}
303
304// AddRemote adds a new remote to the remotes configuration.
305func AddRemote(name, url, token string, insecureTLS bool) error {
306	config, err := LoadRemotes("")
307	if err != nil {
308		return err
309	}
310
311	config.Remotes[name] = RemoteEntry{
312		URL:         url,
313		Token:       token,
314		InsecureTLS: insecureTLS,
315	}
316
317	return SaveRemotes("", config)
318}
319
320// RemoveRemote removes a remote from the remotes configuration.
321func RemoveRemote(name string) error {
322	config, err := LoadRemotes("")
323	if err != nil {
324		return err
325	}
326
327	delete(config.Remotes, name)
328
329	// Clear active if it was the removed remote
330	if config.Active == name {
331		config.Active = ""
332	}
333
334	return SaveRemotes("", config)
335}
336
337// SetActiveRemote sets the active remote.
338func SetActiveRemote(name string) error {
339	config, err := LoadRemotes("")
340	if err != nil {
341		return err
342	}
343
344	// Verify remote exists
345	if _, ok := config.Remotes[name]; !ok {
346		return fmt.Errorf("%w: %s", domain.ErrRemoteNotFound, name)
347	}
348
349	config.Active = name
350
351	return SaveRemotes("", config)
352}
353
354// ClearRemoteToken removes the token and token_env fields for a named remote
355// from the remotes TOML config, and deletes the pass store entry if present.
356func ClearRemoteToken(name string) error {
357	// Delete from pass (best-effort, no error if pass unavailable)
358	_ = passDeleteToken(name)
359
360	// Clear TOML fields
361	config, err := LoadRemotes("")
362	if err != nil {
363		return err
364	}
365
366	entry, ok := config.Remotes[name]
367	if !ok {
368		return fmt.Errorf("%w: %s", domain.ErrRemoteNotFound, name)
369	}
370
371	entry.Token = ""
372	entry.TokenEnv = ""
373	config.Remotes[name] = entry
374
375	return SaveRemotes("", config)
376}
377
378// ListRemotes returns all saved remotes.
379func ListRemotes() (map[string]RemoteEntry, string, error) {
380	config, err := LoadRemotes("")
381	if err != nil {
382		return nil, "", err
383	}
384
385	return config.Remotes, config.Active, nil
386}

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