v2.31.0-dev18 7b9923f

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	remotes, _ := LoadRemotes("")
245
246	name, url, found := resolveTarget(flagRemote, remotes)
247	if !found && flagRemote != "" && !strings.HasPrefix(flagRemote, "http://") && !strings.HasPrefix(flagRemote, "https://") {
248		return nil, false
249	}
250	if !found {
251		if envRemote := os.Getenv("GORDON_REMOTE"); envRemote != "" {
252			name, url, found = resolveTarget(envRemote, remotes)
253		}
254	}
255	if !found && remotes != nil && remotes.Active != "" {
256		if entry, ok := remotes.Remotes[remotes.Active]; ok {
257			name = remotes.Active
258			url = entry.URL
259			found = true
260		}
261	}
262	if !found {
263		return nil, false
264	}
265
266	return &ResolvedRemote{
267		Name:        name,
268		URL:         url,
269		Token:       resolveTokenForTarget(flagToken, name, remotes),
270		InsecureTLS: resolveInsecureForTarget(flagInsecure, name, remotes),
271	}, true
272}
273
274// resolveTarget checks if value is a saved remote name or an ad-hoc URL.
275func resolveTarget(value string, remotes *ClientConfig) (name, url string, found bool) {
276	if value == "" {
277		return "", "", false
278	}
279	if remotes != nil {
280		if entry, ok := remotes.Remotes[value]; ok {
281			return value, entry.URL, true
282		}
283	}
284	if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
285		return "", value, true
286	}
287	return "", "", false
288}
289
290// AddRemote adds a new remote to the remotes configuration.
291func AddRemote(name, url, token string, insecureTLS bool) error {
292	config, err := LoadRemotes("")
293	if err != nil {
294		return err
295	}
296
297	config.Remotes[name] = RemoteEntry{
298		URL:         url,
299		Token:       token,
300		InsecureTLS: insecureTLS,
301	}
302
303	return SaveRemotes("", config)
304}
305
306// RemoveRemote removes a remote from the remotes configuration.
307func RemoveRemote(name string) error {
308	config, err := LoadRemotes("")
309	if err != nil {
310		return err
311	}
312
313	delete(config.Remotes, name)
314
315	// Clear active if it was the removed remote
316	if config.Active == name {
317		config.Active = ""
318	}
319
320	return SaveRemotes("", config)
321}
322
323// SetActiveRemote sets the active remote.
324func SetActiveRemote(name string) error {
325	config, err := LoadRemotes("")
326	if err != nil {
327		return err
328	}
329
330	// Verify remote exists
331	if _, ok := config.Remotes[name]; !ok {
332		return fmt.Errorf("%w: %s", domain.ErrRemoteNotFound, name)
333	}
334
335	config.Active = name
336
337	return SaveRemotes("", config)
338}
339
340// ClearRemoteToken removes the token and token_env fields for a named remote
341// from the remotes TOML config, and deletes the pass store entry if present.
342func ClearRemoteToken(name string) error {
343	// Delete from pass (best-effort, no error if pass unavailable)
344	_ = passDeleteToken(name)
345
346	// Clear TOML fields
347	config, err := LoadRemotes("")
348	if err != nil {
349		return err
350	}
351
352	entry, ok := config.Remotes[name]
353	if !ok {
354		return fmt.Errorf("%w: %s", domain.ErrRemoteNotFound, name)
355	}
356
357	entry.Token = ""
358	entry.TokenEnv = ""
359	config.Remotes[name] = entry
360
361	return SaveRemotes("", config)
362}
363
364// ListRemotes returns all saved remotes.
365func ListRemotes() (map[string]RemoteEntry, string, error) {
366	config, err := LoadRemotes("")
367	if err != nil {
368		return nil, "", err
369	}
370
371	return config.Remotes, config.Active, nil
372}

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