v2.31.0-dev5 4162c86

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	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
116func 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
124// SaveRemotes saves the remotes configuration.
125func SaveRemotes(path string, config *ClientConfig) error {
126	if path == "" {
127		path = DefaultRemotesPath()
128	}
129
130	// Ensure directory exists
131	dir := filepath.Dir(path)
132	if err := os.MkdirAll(dir, 0700); err != nil {
133		return fmt.Errorf("failed to create config directory: %w", err)
134	}
135
136	data, err := toml.Marshal(config)
137	if err != nil {
138		return fmt.Errorf("failed to marshal remotes: %w", err)
139	}
140
141	if err := os.WriteFile(path, data, 0600); err != nil {
142		return fmt.Errorf("failed to write remotes: %w", err)
143	}
144
145	return nil
146}
147
148func resolveRemoteEntryToken(remote *RemoteEntry) string {
149	if remote == nil {
150		return ""
151	}
152	if remote.Token != "" {
153		return remote.Token
154	}
155	if remote.TokenEnv != "" {
156		return os.Getenv(remote.TokenEnv)
157	}
158	return ""
159}
160
161// ResolveTokenForRemote resolves the token for a named remote.
162// Precedence: TOML token field > token_env > pass store.
163func ResolveTokenForRemote(name string, entry RemoteEntry) string {
164	if token := resolveRemoteEntryToken(&entry); token != "" {
165		return token
166	}
167	if passAvailable() {
168		if token, err := passReadToken(name); err == nil && token != "" {
169			return token
170		}
171	}
172	return ""
173}
174
175// ResolvedRemote holds the fully resolved remote target.
176// Name is empty for ad-hoc URLs passed directly via flag or env.
177type ResolvedRemote struct {
178	Name        string
179	URL         string
180	Token       string
181	InsecureTLS bool
182}
183
184// DisplayName returns the remote name, or the URL if unnamed (ad-hoc).
185func (r *ResolvedRemote) DisplayName() string {
186	if r.Name != "" {
187		return r.Name
188	}
189	return r.URL
190}
191
192// resolveTokenForTarget resolves the authentication token.
193// Precedence: flag > GORDON_TOKEN env > stored token for named remote.
194func resolveTokenForTarget(flagToken, name string, remotes *ClientConfig) string {
195	if flagToken != "" {
196		return flagToken
197	}
198	if envToken := os.Getenv("GORDON_TOKEN"); envToken != "" {
199		return envToken
200	}
201	if name != "" && remotes != nil {
202		if entry, ok := remotes.Remotes[name]; ok {
203			return ResolveTokenForRemote(name, entry)
204		}
205	}
206	return ""
207}
208
209// resolveInsecureForTarget resolves InsecureTLS setting.
210// Precedence: flag > GORDON_INSECURE env > remote entry field.
211func resolveInsecureForTarget(flagInsecure bool, name string, remotes *ClientConfig) bool {
212	if flagInsecure {
213		return true
214	}
215	if env := os.Getenv("GORDON_INSECURE"); env != "" {
216		if v, err := strconv.ParseBool(env); err == nil && v {
217			return true
218		}
219	}
220	if name != "" && remotes != nil {
221		if entry, ok := remotes.Remotes[name]; ok {
222			return entry.InsecureTLS
223		}
224	}
225	return false
226}
227
228// Resolve resolves the remote target from flags, environment, and config.
229// Returns nil, false when no remote is configured (local mode).
230//
231// URL precedence: flag > GORDON_REMOTE env > active remote.
232// The flag and env values are treated as a saved remote name first;
233// if no match and the value starts with http:// or https://, used as ad-hoc URL.
234//
235// Token precedence: flag > GORDON_TOKEN env > named remote (pass > TOML token > token_env).
236// InsecureTLS precedence: flag > GORDON_INSECURE env > remote entry field.
237func Resolve(flagRemote, flagToken string, flagInsecure bool) (*ResolvedRemote, bool) {
238	remotes, _ := LoadRemotes("")
239
240	name, url, found := resolveTarget(flagRemote, remotes)
241	if !found && flagRemote != "" && !strings.HasPrefix(flagRemote, "http://") && !strings.HasPrefix(flagRemote, "https://") {
242		return nil, false
243	}
244	if !found {
245		if envRemote := os.Getenv("GORDON_REMOTE"); envRemote != "" {
246			name, url, found = resolveTarget(envRemote, remotes)
247		}
248	}
249	if !found && remotes != nil && remotes.Active != "" {
250		if entry, ok := remotes.Remotes[remotes.Active]; ok {
251			name = remotes.Active
252			url = entry.URL
253			found = true
254		}
255	}
256	if !found {
257		return nil, false
258	}
259
260	return &ResolvedRemote{
261		Name:        name,
262		URL:         url,
263		Token:       resolveTokenForTarget(flagToken, name, remotes),
264		InsecureTLS: resolveInsecureForTarget(flagInsecure, name, remotes),
265	}, true
266}
267
268// resolveTarget checks if value is a saved remote name or an ad-hoc URL.
269func resolveTarget(value string, remotes *ClientConfig) (name, url string, found bool) {
270	if value == "" {
271		return "", "", false
272	}
273	if remotes != nil {
274		if entry, ok := remotes.Remotes[value]; ok {
275			return value, entry.URL, true
276		}
277	}
278	if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
279		return "", value, true
280	}
281	return "", "", false
282}
283
284// AddRemote adds a new remote to the remotes configuration.
285func AddRemote(name, url, token string, insecureTLS bool) error {
286	config, err := LoadRemotes("")
287	if err != nil {
288		return err
289	}
290
291	config.Remotes[name] = RemoteEntry{
292		URL:         url,
293		Token:       token,
294		InsecureTLS: insecureTLS,
295	}
296
297	return SaveRemotes("", config)
298}
299
300// RemoveRemote removes a remote from the remotes configuration.
301func RemoveRemote(name string) error {
302	config, err := LoadRemotes("")
303	if err != nil {
304		return err
305	}
306
307	delete(config.Remotes, name)
308
309	// Clear active if it was the removed remote
310	if config.Active == name {
311		config.Active = ""
312	}
313
314	return SaveRemotes("", config)
315}
316
317// SetActiveRemote sets the active remote.
318func SetActiveRemote(name string) error {
319	config, err := LoadRemotes("")
320	if err != nil {
321		return err
322	}
323
324	// Verify remote exists
325	if _, ok := config.Remotes[name]; !ok {
326		return fmt.Errorf("%w: %s", domain.ErrRemoteNotFound, name)
327	}
328
329	config.Active = name
330
331	return SaveRemotes("", config)
332}
333
334// ClearRemoteToken removes the token and token_env fields for a named remote
335// from the remotes TOML config, and deletes the pass store entry if present.
336func ClearRemoteToken(name string) error {
337	// Delete from pass (best-effort, no error if pass unavailable)
338	_ = passDeleteToken(name)
339
340	// Clear TOML fields
341	config, err := LoadRemotes("")
342	if err != nil {
343		return err
344	}
345
346	entry, ok := config.Remotes[name]
347	if !ok {
348		return fmt.Errorf("%w: %s", domain.ErrRemoteNotFound, name)
349	}
350
351	entry.Token = ""
352	entry.TokenEnv = ""
353	config.Remotes[name] = entry
354
355	return SaveRemotes("", config)
356}
357
358// ListRemotes returns all saved remotes.
359func ListRemotes() (map[string]RemoteEntry, string, error) {
360	config, err := LoadRemotes("")
361	if err != nil {
362		return nil, "", err
363	}
364
365	return config.Remotes, config.Active, nil
366}

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