detached f8408e0

Merge pull request #155 from bnema/fix/remote-state-store

fix(remote): keep host state out of config
commit
f8408e019fb827f7b35ca7f01904daf2afd2a176
author
bnema · · 2026-07-30 08:52 UTC
committer
GitHub · · 2026-07-30 08:52 UTC
merge of
a3a5b69 + 59b57b4 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
8 files · +2 -839
M internal/adapters/config/config.go +1 -26
@@ -21,13 +21,11 @@ const pollInterval = 2 * time.Second
2121 var (
2222 processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`)
2323 percentagePattern = regexp.MustCompile(`^[0-9]{1,3}%$`)
24- sectionPattern = regexp.MustCompile(`^\[([^\]]+)\]$`)
2524 )
2625
2726 // Parse reads vev's flat action = value config format. Duplicate action keys
2827 // are accepted with a warning; the last value wins while first-seen action order
29-// is preserved for binding conflict resolution. An optional [remote] section
30-// accepts enabled/remember/hosts with TOML-style true/false values.
28+// is preserved for binding conflict resolution.
3129 func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
3230 cfg := domain.Defaults()
3331 var warnings []domain.Warning
@@ -37,8 +35,6 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
3735 seenPaletteKeys := make(map[string]bool)
3836 seenNavKeys := make(map[string]bool)
3937 seenTabsKeys := make(map[string]bool)
40- seenRemoteKeys := make(map[string]bool)
41- section := ""
4238
4339 scanner := bufio.NewScanner(r)
4440 lineNo := 0
@@ -53,17 +49,6 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
5349 continue
5450 }
5551
56- if matches := sectionPattern.FindStringSubmatch(line); matches != nil {
57- name := matches[1]
58- if name == "remote" {
59- section = "remote"
60- } else {
61- section = "unknown:" + name
62- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("unknown section %q", name)})
63- }
64- continue
65- }
66-
6752 key, value, ok := strings.Cut(line, "=")
6853 if !ok {
6954 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing '='"})
@@ -76,16 +61,6 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
7661 continue
7762 }
7863
79- if strings.HasPrefix(section, "unknown:") {
80- continue
81- }
82- if section == "remote" {
83- var remoteWarnings []domain.Warning
84- cfg.Remote, remoteWarnings = parseRemoteKey(cfg.Remote, seenRemoteKeys, key, value, lineNo)
85- warnings = append(warnings, remoteWarnings...)
86- continue
87- }
88-
8964 switch {
9065 case key == "theme":
9166 mode, ok := parseTheme(value)
@@ -21,13 +21,11 @@ const pollInterval = 2 * time.Second
21 var (21 var (
22 processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`)22 processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`)
23 percentagePattern = regexp.MustCompile(`^[0-9]{1,3}%$`)23 percentagePattern = regexp.MustCompile(`^[0-9]{1,3}%$`)
24- sectionPattern = regexp.MustCompile(`^\[([^\]]+)\]$`)
25 )24 )
26 25
27 // Parse reads vev's flat action = value config format. Duplicate action keys26 // Parse reads vev's flat action = value config format. Duplicate action keys
28 // are accepted with a warning; the last value wins while first-seen action order27 // are accepted with a warning; the last value wins while first-seen action order
29-// is preserved for binding conflict resolution. An optional [remote] section28+// is preserved for binding conflict resolution.
30-// accepts enabled/remember/hosts with TOML-style true/false values.
31 func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {29 func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
32 cfg := domain.Defaults()30 cfg := domain.Defaults()
33 var warnings []domain.Warning31 var warnings []domain.Warning
@@ -37,8 +35,6 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
37 seenPaletteKeys := make(map[string]bool)35 seenPaletteKeys := make(map[string]bool)
38 seenNavKeys := make(map[string]bool)36 seenNavKeys := make(map[string]bool)
39 seenTabsKeys := make(map[string]bool)37 seenTabsKeys := make(map[string]bool)
40- seenRemoteKeys := make(map[string]bool)
41- section := ""
42 38
43 scanner := bufio.NewScanner(r)39 scanner := bufio.NewScanner(r)
44 lineNo := 040 lineNo := 0
@@ -53,17 +49,6 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
53 continue49 continue
54 }50 }
55 51
56- if matches := sectionPattern.FindStringSubmatch(line); matches != nil {
57- name := matches[1]
58- if name == "remote" {
59- section = "remote"
60- } else {
61- section = "unknown:" + name
62- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("unknown section %q", name)})
63- }
64- continue
65- }
66-
67 key, value, ok := strings.Cut(line, "=")52 key, value, ok := strings.Cut(line, "=")
68 if !ok {53 if !ok {
69 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing '='"})54 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing '='"})
@@ -76,16 +61,6 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
76 continue61 continue
77 }62 }
78 63
79- if strings.HasPrefix(section, "unknown:") {
80- continue
81- }
82- if section == "remote" {
83- var remoteWarnings []domain.Warning
84- cfg.Remote, remoteWarnings = parseRemoteKey(cfg.Remote, seenRemoteKeys, key, value, lineNo)
85- warnings = append(warnings, remoteWarnings...)
86- continue
87- }
88-
89 switch {64 switch {
90 case key == "theme":65 case key == "theme":
91 mode, ok := parseTheme(value)66 mode, ok := parseTheme(value)
M internal/adapters/config/config_test.go +0 -3
@@ -358,9 +358,6 @@ func TestParse(t *testing.T) {
358358 if !tt.want.Palette.AnchorSet {
359359 tt.want.Palette = domain.Defaults().Palette
360360 }
361- if tt.want.Remote.Hosts == nil {
362- tt.want.Remote = domain.Defaults().Remote
363- }
364361 got, warnings, err := Parse(strings.NewReader(tt.input))
365362 if err != nil {
366363 t.Fatalf("Parse() error = %v", err)
@@ -358,9 +358,6 @@ func TestParse(t *testing.T) {
358 if !tt.want.Palette.AnchorSet {358 if !tt.want.Palette.AnchorSet {
359 tt.want.Palette = domain.Defaults().Palette359 tt.want.Palette = domain.Defaults().Palette
360 }360 }
361- if tt.want.Remote.Hosts == nil {
362- tt.want.Remote = domain.Defaults().Remote
363- }
364 got, warnings, err := Parse(strings.NewReader(tt.input))361 got, warnings, err := Parse(strings.NewReader(tt.input))
365 if err != nil {362 if err != nil {
366 t.Fatalf("Parse() error = %v", err)363 t.Fatalf("Parse() error = %v", err)
D internal/adapters/config/remote.go +0 -328
@@ -1,328 +0,0 @@
1-package config
2-
3-import (
4- "bytes"
5- "encoding/json"
6- "errors"
7- "fmt"
8- "os"
9- "path/filepath"
10- "strings"
11- "syscall"
12-
13- "github.com/bnema/vev/internal/domain"
14- "github.com/bnema/vev/pkg/safedir"
15-)
16-
17-func parseRemoteKey(remote domain.RemoteConfig, seen map[string]bool, key, value string, lineNo int) (domain.RemoteConfig, []domain.Warning) {
18- var warnings []domain.Warning
19- switch key {
20- case "enabled", "remember", "hosts":
21- warnings = warnDuplicateKey(warnings, seen, key, lineNo)
22- default:
23- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("unknown key %q", key)})
24- return remote, warnings
25- }
26-
27- switch key {
28- case "enabled":
29- on, ok := parseTrueFalse(value)
30- if !ok {
31- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid enabled %q", value)})
32- return remote, warnings
33- }
34- remote.Enabled = on
35- case "remember":
36- on, ok := parseTrueFalse(value)
37- if !ok {
38- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid remember %q", value)})
39- return remote, warnings
40- }
41- remote.Remember = on
42- case "hosts":
43- hosts, hostWarnings := parseRemoteHosts(value, lineNo)
44- warnings = append(warnings, hostWarnings...)
45- if hosts == nil {
46- return remote, warnings
47- }
48- remote.Hosts = hosts
49- }
50- return remote, warnings
51-}
52-
53-func parseTrueFalse(value string) (bool, bool) {
54- switch strings.TrimSpace(value) {
55- case "true":
56- return true, true
57- case "false":
58- return false, true
59- default:
60- return false, false
61- }
62-}
63-
64-func parseRemoteHosts(value string, lineNo int) ([]string, []domain.Warning) {
65- var raw json.RawMessage
66- if err := json.Unmarshal([]byte(value), &raw); err != nil {
67- return nil, []domain.Warning{{Line: lineNo, Msg: fmt.Sprintf("invalid hosts %q", value)}}
68- }
69- if len(raw) == 0 || raw[0] != '[' {
70- return nil, []domain.Warning{{Line: lineNo, Msg: fmt.Sprintf("invalid hosts %q", value)}}
71- }
72- var items []any
73- if err := json.Unmarshal(raw, &items); err != nil {
74- return nil, []domain.Warning{{Line: lineNo, Msg: fmt.Sprintf("invalid hosts %q", value)}}
75- }
76- out := make([]string, 0, len(items))
77- seen := make(map[string]struct{}, len(items))
78- var warnings []domain.Warning
79- ok := true
80- for _, item := range items {
81- host, isString := item.(string)
82- if !isString {
83- return nil, []domain.Warning{{Line: lineNo, Msg: fmt.Sprintf("invalid hosts %q", value)}}
84- }
85- if err := domain.ValidateRemoteHostTarget(host); err != nil {
86- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid remote host %q: %v", host, err)})
87- ok = false
88- continue
89- }
90- if _, dup := seen[host]; dup {
91- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate host %q", host)})
92- ok = false
93- continue
94- }
95- seen[host] = struct{}{}
96- out = append(out, host)
97- }
98- if !ok {
99- return nil, warnings
100- }
101- return out, nil
102-}
103-
104-// UpdateRemoteHosts creates or replaces the pinned hosts list in path's [remote]
105-// section while preserving unrelated config text. Writes use a private parent
106-// directory and same-directory temp + sync + rename + directory sync.
107-func UpdateRemoteHosts(path string, hosts []string) error {
108- hosts = domain.UniqueRemoteHostTargets(hosts)
109- for _, host := range hosts {
110- if err := domain.ValidateRemoteHostTarget(host); err != nil {
111- return err
112- }
113- }
114- if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
115- return err
116- }
117-
118- lockFile, err := acquireConfigLock(path)
119- if err != nil {
120- return err
121- }
122- defer func() {
123- _ = releaseConfigLock(lockFile)
124- }()
125-
126- original, err := os.ReadFile(path)
127- if err != nil && !errors.Is(err, os.ErrNotExist) {
128- return err
129- }
130- missing := errors.Is(err, os.ErrNotExist)
131-
132- hostsLine, err := formatRemoteHostsLine(hosts)
133- if err != nil {
134- return err
135- }
136-
137- var next []byte
138- if missing || len(bytes.TrimSpace(original)) == 0 {
139- next = []byte("[remote]\n" + hostsLine + "\n")
140- } else {
141- next = rewriteRemoteHosts(original, hostsLine)
142- }
143- return writeConfigAtomic(path, next)
144-}
145-
146-func formatRemoteHostsLine(hosts []string) (string, error) {
147- var b strings.Builder
148- b.WriteString("hosts = [")
149- for i, host := range hosts {
150- if i > 0 {
151- b.WriteString(", ")
152- }
153- encoded, err := json.Marshal(host)
154- if err != nil {
155- return "", err
156- }
157- b.Write(encoded)
158- }
159- b.WriteByte(']')
160- return b.String(), nil
161-}
162-
163-func rewriteRemoteHosts(original []byte, hostsLine string) []byte {
164- lines := splitKeepNewlines(original)
165- remoteIdx := -1
166- hostsIdx := -1
167- inRemote := false
168- for i, line := range lines {
169- trimmed := strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r"))
170- if trimmed == "" || strings.HasPrefix(trimmed, "#") {
171- continue
172- }
173- trimmed = stripInlineComment(trimmed)
174- if matches := sectionPattern.FindStringSubmatch(trimmed); matches != nil {
175- inRemote = matches[1] == "remote"
176- if inRemote && remoteIdx < 0 {
177- remoteIdx = i
178- }
179- continue
180- }
181- if !inRemote {
182- continue
183- }
184- key, _, ok := strings.Cut(trimmed, "=")
185- if !ok {
186- continue
187- }
188- if strings.TrimSpace(key) == "hosts" {
189- hostsIdx = i
190- }
191- }
192-
193- ending := dominantLineEnding(original)
194-
195- switch {
196- case hostsIdx >= 0:
197- prefix := lines[hostsIdx]
198- nl := "\n"
199- if strings.HasSuffix(prefix, "\r\n") {
200- nl = "\r\n"
201- } else if !strings.HasSuffix(prefix, "\n") {
202- nl = ""
203- }
204- lines[hostsIdx] = hostsLine + nl
205- case remoteIdx >= 0:
206- insertAt := remoteIdx + 1
207- for insertAt < len(lines) {
208- trimmed := strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(lines[insertAt], "\n"), "\r"))
209- if matches := sectionPattern.FindStringSubmatch(stripInlineComment(trimmed)); matches != nil {
210- break
211- }
212- insertAt++
213- }
214- hostsEntry := hostsLine + ending
215- lines = append(lines[:insertAt], append([]string{hostsEntry}, lines[insertAt:]...)...)
216- default:
217- if len(lines) > 0 {
218- last := lines[len(lines)-1]
219- if last != "" && !strings.HasSuffix(last, "\n") && !strings.HasSuffix(last, "\r\n") {
220- lines[len(lines)-1] = last + ending
221- }
222- }
223- lines = append(lines, "[remote]"+ending, hostsLine+ending)
224- }
225-
226- var b strings.Builder
227- for _, line := range lines {
228- b.WriteString(line)
229- }
230- return []byte(b.String())
231-}
232-
233-func dominantLineEnding(data []byte) string {
234- crlf := 0
235- lf := 0
236- for i := 0; i < len(data); i++ {
237- if data[i] != '\n' {
238- continue
239- }
240- if i > 0 && data[i-1] == '\r' {
241- crlf++
242- } else {
243- lf++
244- }
245- }
246- if crlf > lf {
247- return "\r\n"
248- }
249- return "\n"
250-}
251-
252-func acquireConfigLock(path string) (*os.File, error) {
253- file, err := os.OpenFile(path+".lock", os.O_RDWR|os.O_CREATE, 0o600)
254- if err != nil {
255- return nil, err
256- }
257- if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil {
258- _ = file.Close()
259- return nil, err
260- }
261- return file, nil
262-}
263-
264-func releaseConfigLock(file *os.File) error {
265- if file == nil {
266- return nil
267- }
268- return errors.Join(syscall.Flock(int(file.Fd()), syscall.LOCK_UN), file.Close())
269-}
270-
271-func splitKeepNewlines(data []byte) []string {
272- if len(data) == 0 {
273- return nil
274- }
275- var lines []string
276- start := 0
277- for i := 0; i < len(data); i++ {
278- if data[i] == '\n' {
279- lines = append(lines, string(data[start:i+1]))
280- start = i + 1
281- }
282- }
283- if start < len(data) {
284- lines = append(lines, string(data[start:]))
285- }
286- return lines
287-}
288-
289-func writeConfigAtomic(path string, data []byte) error {
290- dir := filepath.Dir(path)
291- file, err := os.CreateTemp(dir, ".vev-config-*.tmp")
292- if err != nil {
293- return err
294- }
295- tmp := file.Name()
296- committed := false
297- defer func() {
298- if !committed {
299- _ = file.Close()
300- _ = os.Remove(tmp)
301- }
302- }()
303- if err := file.Chmod(0o600); err != nil {
304- return err
305- }
306- if _, err := file.Write(data); err != nil {
307- return err
308- }
309- if err := file.Sync(); err != nil {
310- return err
311- }
312- if err := file.Close(); err != nil {
313- return err
314- }
315- if err := os.Rename(tmp, path); err != nil {
316- return err
317- }
318- committed = true
319- return syncDir(dir)
320-}
321-
322-func syncDir(dir string) error {
323- file, err := os.Open(dir)
324- if err != nil {
325- return err
326- }
327- return errors.Join(file.Sync(), file.Close())
328-}
@@ -1,328 +0,0 @@
1-package config
2-
3-import (
4- "bytes"
5- "encoding/json"
6- "errors"
7- "fmt"
8- "os"
9- "path/filepath"
10- "strings"
11- "syscall"
12-
13- "github.com/bnema/vev/internal/domain"
14- "github.com/bnema/vev/pkg/safedir"
15-)
16-
17-func parseRemoteKey(remote domain.RemoteConfig, seen map[string]bool, key, value string, lineNo int) (domain.RemoteConfig, []domain.Warning) {
18- var warnings []domain.Warning
19- switch key {
20- case "enabled", "remember", "hosts":
21- warnings = warnDuplicateKey(warnings, seen, key, lineNo)
22- default:
23- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("unknown key %q", key)})
24- return remote, warnings
25- }
26-
27- switch key {
28- case "enabled":
29- on, ok := parseTrueFalse(value)
30- if !ok {
31- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid enabled %q", value)})
32- return remote, warnings
33- }
34- remote.Enabled = on
35- case "remember":
36- on, ok := parseTrueFalse(value)
37- if !ok {
38- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid remember %q", value)})
39- return remote, warnings
40- }
41- remote.Remember = on
42- case "hosts":
43- hosts, hostWarnings := parseRemoteHosts(value, lineNo)
44- warnings = append(warnings, hostWarnings...)
45- if hosts == nil {
46- return remote, warnings
47- }
48- remote.Hosts = hosts
49- }
50- return remote, warnings
51-}
52-
53-func parseTrueFalse(value string) (bool, bool) {
54- switch strings.TrimSpace(value) {
55- case "true":
56- return true, true
57- case "false":
58- return false, true
59- default:
60- return false, false
61- }
62-}
63-
64-func parseRemoteHosts(value string, lineNo int) ([]string, []domain.Warning) {
65- var raw json.RawMessage
66- if err := json.Unmarshal([]byte(value), &raw); err != nil {
67- return nil, []domain.Warning{{Line: lineNo, Msg: fmt.Sprintf("invalid hosts %q", value)}}
68- }
69- if len(raw) == 0 || raw[0] != '[' {
70- return nil, []domain.Warning{{Line: lineNo, Msg: fmt.Sprintf("invalid hosts %q", value)}}
71- }
72- var items []any
73- if err := json.Unmarshal(raw, &items); err != nil {
74- return nil, []domain.Warning{{Line: lineNo, Msg: fmt.Sprintf("invalid hosts %q", value)}}
75- }
76- out := make([]string, 0, len(items))
77- seen := make(map[string]struct{}, len(items))
78- var warnings []domain.Warning
79- ok := true
80- for _, item := range items {
81- host, isString := item.(string)
82- if !isString {
83- return nil, []domain.Warning{{Line: lineNo, Msg: fmt.Sprintf("invalid hosts %q", value)}}
84- }
85- if err := domain.ValidateRemoteHostTarget(host); err != nil {
86- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid remote host %q: %v", host, err)})
87- ok = false
88- continue
89- }
90- if _, dup := seen[host]; dup {
91- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate host %q", host)})
92- ok = false
93- continue
94- }
95- seen[host] = struct{}{}
96- out = append(out, host)
97- }
98- if !ok {
99- return nil, warnings
100- }
101- return out, nil
102-}
103-
104-// UpdateRemoteHosts creates or replaces the pinned hosts list in path's [remote]
105-// section while preserving unrelated config text. Writes use a private parent
106-// directory and same-directory temp + sync + rename + directory sync.
107-func UpdateRemoteHosts(path string, hosts []string) error {
108- hosts = domain.UniqueRemoteHostTargets(hosts)
109- for _, host := range hosts {
110- if err := domain.ValidateRemoteHostTarget(host); err != nil {
111- return err
112- }
113- }
114- if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
115- return err
116- }
117-
118- lockFile, err := acquireConfigLock(path)
119- if err != nil {
120- return err
121- }
122- defer func() {
123- _ = releaseConfigLock(lockFile)
124- }()
125-
126- original, err := os.ReadFile(path)
127- if err != nil && !errors.Is(err, os.ErrNotExist) {
128- return err
129- }
130- missing := errors.Is(err, os.ErrNotExist)
131-
132- hostsLine, err := formatRemoteHostsLine(hosts)
133- if err != nil {
134- return err
135- }
136-
137- var next []byte
138- if missing || len(bytes.TrimSpace(original)) == 0 {
139- next = []byte("[remote]\n" + hostsLine + "\n")
140- } else {
141- next = rewriteRemoteHosts(original, hostsLine)
142- }
143- return writeConfigAtomic(path, next)
144-}
145-
146-func formatRemoteHostsLine(hosts []string) (string, error) {
147- var b strings.Builder
148- b.WriteString("hosts = [")
149- for i, host := range hosts {
150- if i > 0 {
151- b.WriteString(", ")
152- }
153- encoded, err := json.Marshal(host)
154- if err != nil {
155- return "", err
156- }
157- b.Write(encoded)
158- }
159- b.WriteByte(']')
160- return b.String(), nil
161-}
162-
163-func rewriteRemoteHosts(original []byte, hostsLine string) []byte {
164- lines := splitKeepNewlines(original)
165- remoteIdx := -1
166- hostsIdx := -1
167- inRemote := false
168- for i, line := range lines {
169- trimmed := strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r"))
170- if trimmed == "" || strings.HasPrefix(trimmed, "#") {
171- continue
172- }
173- trimmed = stripInlineComment(trimmed)
174- if matches := sectionPattern.FindStringSubmatch(trimmed); matches != nil {
175- inRemote = matches[1] == "remote"
176- if inRemote && remoteIdx < 0 {
177- remoteIdx = i
178- }
179- continue
180- }
181- if !inRemote {
182- continue
183- }
184- key, _, ok := strings.Cut(trimmed, "=")
185- if !ok {
186- continue
187- }
188- if strings.TrimSpace(key) == "hosts" {
189- hostsIdx = i
190- }
191- }
192-
193- ending := dominantLineEnding(original)
194-
195- switch {
196- case hostsIdx >= 0:
197- prefix := lines[hostsIdx]
198- nl := "\n"
199- if strings.HasSuffix(prefix, "\r\n") {
200- nl = "\r\n"
201- } else if !strings.HasSuffix(prefix, "\n") {
202- nl = ""
203- }
204- lines[hostsIdx] = hostsLine + nl
205- case remoteIdx >= 0:
206- insertAt := remoteIdx + 1
207- for insertAt < len(lines) {
208- trimmed := strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(lines[insertAt], "\n"), "\r"))
209- if matches := sectionPattern.FindStringSubmatch(stripInlineComment(trimmed)); matches != nil {
210- break
211- }
212- insertAt++
213- }
214- hostsEntry := hostsLine + ending
215- lines = append(lines[:insertAt], append([]string{hostsEntry}, lines[insertAt:]...)...)
216- default:
217- if len(lines) > 0 {
218- last := lines[len(lines)-1]
219- if last != "" && !strings.HasSuffix(last, "\n") && !strings.HasSuffix(last, "\r\n") {
220- lines[len(lines)-1] = last + ending
221- }
222- }
223- lines = append(lines, "[remote]"+ending, hostsLine+ending)
224- }
225-
226- var b strings.Builder
227- for _, line := range lines {
228- b.WriteString(line)
229- }
230- return []byte(b.String())
231-}
232-
233-func dominantLineEnding(data []byte) string {
234- crlf := 0
235- lf := 0
236- for i := 0; i < len(data); i++ {
237- if data[i] != '\n' {
238- continue
239- }
240- if i > 0 && data[i-1] == '\r' {
241- crlf++
242- } else {
243- lf++
244- }
245- }
246- if crlf > lf {
247- return "\r\n"
248- }
249- return "\n"
250-}
251-
252-func acquireConfigLock(path string) (*os.File, error) {
253- file, err := os.OpenFile(path+".lock", os.O_RDWR|os.O_CREATE, 0o600)
254- if err != nil {
255- return nil, err
256- }
257- if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil {
258- _ = file.Close()
259- return nil, err
260- }
261- return file, nil
262-}
263-
264-func releaseConfigLock(file *os.File) error {
265- if file == nil {
266- return nil
267- }
268- return errors.Join(syscall.Flock(int(file.Fd()), syscall.LOCK_UN), file.Close())
269-}
270-
271-func splitKeepNewlines(data []byte) []string {
272- if len(data) == 0 {
273- return nil
274- }
275- var lines []string
276- start := 0
277- for i := 0; i < len(data); i++ {
278- if data[i] == '\n' {
279- lines = append(lines, string(data[start:i+1]))
280- start = i + 1
281- }
282- }
283- if start < len(data) {
284- lines = append(lines, string(data[start:]))
285- }
286- return lines
287-}
288-
289-func writeConfigAtomic(path string, data []byte) error {
290- dir := filepath.Dir(path)
291- file, err := os.CreateTemp(dir, ".vev-config-*.tmp")
292- if err != nil {
293- return err
294- }
295- tmp := file.Name()
296- committed := false
297- defer func() {
298- if !committed {
299- _ = file.Close()
300- _ = os.Remove(tmp)
301- }
302- }()
303- if err := file.Chmod(0o600); err != nil {
304- return err
305- }
306- if _, err := file.Write(data); err != nil {
307- return err
308- }
309- if err := file.Sync(); err != nil {
310- return err
311- }
312- if err := file.Close(); err != nil {
313- return err
314- }
315- if err := os.Rename(tmp, path); err != nil {
316- return err
317- }
318- committed = true
319- return syncDir(dir)
320-}
321-
322-func syncDir(dir string) error {
323- file, err := os.Open(dir)
324- if err != nil {
325- return err
326- }
327- return errors.Join(file.Sync(), file.Close())
328-}
D internal/adapters/config/remote_test.go +0 -452
@@ -1,452 +0,0 @@
1-package config
2-
3-import (
4- "os"
5- "path/filepath"
6- "reflect"
7- "strconv"
8- "strings"
9- "testing"
10-
11- "github.com/bnema/vev/internal/domain"
12- "github.com/bnema/vev/pkg/safedir"
13- "github.com/stretchr/testify/require"
14-)
15-
16-func TestParseRemote(t *testing.T) {
17- t.Parallel()
18-
19- tests := []struct {
20- name string
21- input string
22- want domain.RemoteConfig
23- wantWarnings []domain.Warning
24- wantTheme domain.ThemeMode
25- checkTheme bool
26- wantBindingEntries []domain.ConfigEntry
27- checkBindings bool
28- }{
29- {
30- name: "defaults when section absent",
31- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
32- },
33- {
34- name: "complete remote block",
35- input: `[remote]
36-enabled = true
37-remember = true
38-hosts = ["arch", "build@mule"]
39-`,
40- want: domain.RemoteConfig{
41- Enabled: true,
42- Remember: true,
43- Hosts: []string{"arch", "build@mule"},
44- },
45- },
46- {
47- name: "enabled false",
48- input: `[remote]
49-enabled = false
50-`,
51- want: domain.RemoteConfig{Enabled: false, Remember: true, Hosts: []string{}},
52- },
53- {
54- name: "remember false",
55- input: `[remote]
56-remember = false
57-`,
58- want: domain.RemoteConfig{Enabled: true, Remember: false, Hosts: []string{}},
59- },
60- {
61- name: "legacy on off rejected and defaults retained",
62- input: `[remote]
63-enabled = off
64-remember = on
65-`,
66- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
67- wantWarnings: []domain.Warning{
68- {Line: 2, Msg: `invalid enabled "off"`},
69- {Line: 3, Msg: `invalid remember "on"`},
70- },
71- },
72- {
73- name: "empty hosts array",
74- input: `[remote]
75-hosts = []
76-`,
77- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
78- },
79- {
80- name: "duplicate keys warn and last valid wins",
81- input: `[remote]
82-enabled = false
83-enabled = true
84-remember = false
85-remember = true
86-hosts = ["arch"]
87-hosts = ["mule"]
88-`,
89- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{"mule"}},
90- wantWarnings: []domain.Warning{
91- {Line: 3, Msg: `duplicate key "enabled"`},
92- {Line: 5, Msg: `duplicate key "remember"`},
93- {Line: 7, Msg: `duplicate key "hosts"`},
94- },
95- },
96- {
97- name: "invalid booleans warn and retain defaults",
98- input: `[remote]
99-enabled = yes
100-remember = True
101-`,
102- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
103- wantWarnings: []domain.Warning{
104- {Line: 2, Msg: `invalid enabled "yes"`},
105- {Line: 3, Msg: `invalid remember "True"`},
106- },
107- },
108- {
109- name: "invalid hosts array retains default",
110- input: `[remote]
111-hosts = [1, "arch"]
112-`,
113- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
114- wantWarnings: []domain.Warning{
115- {Line: 2, Msg: `invalid hosts "[1, \"arch\"]"`},
116- },
117- },
118- {
119- name: "malformed hosts array retains default",
120- input: `[remote]
121-hosts = not-an-array
122-`,
123- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
124- wantWarnings: []domain.Warning{
125- {Line: 2, Msg: `invalid hosts "not-an-array"`},
126- },
127- },
128- {
129- name: "null hosts retains default",
130- input: `[remote]
131-hosts = null
132-`,
133- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
134- wantWarnings: []domain.Warning{
135- {Line: 2, Msg: `invalid hosts "null"`},
136- },
137- },
138- {
139- name: "invalid and duplicate host entries retain default",
140- input: `[remote]
141-hosts = ["arch", "arch", " bad", "ok"]
142-`,
143- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
144- wantWarnings: []domain.Warning{
145- {Line: 2, Msg: `duplicate host "arch"`},
146- {Line: 2, Msg: `invalid remote host " bad": remote host target " bad" has surrounding whitespace`},
147- },
148- },
149- {
150- name: "invalid hosts assignment retains previous value",
151- input: `[remote]
152-hosts = ["arch"]
153-hosts = ["arch", " bad", "ok"]
154-`,
155- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{"arch"}},
156- wantWarnings: []domain.Warning{
157- {Line: 3, Msg: `duplicate key "hosts"`},
158- {Line: 3, Msg: `invalid remote host " bad": remote host target " bad" has surrounding whitespace`},
159- },
160- },
161- {
162- name: "unknown remote key warns",
163- input: `[remote]
164-proxy = true
165-enabled = false
166-`,
167- want: domain.RemoteConfig{Enabled: false, Remember: true, Hosts: []string{}},
168- wantWarnings: []domain.Warning{
169- {Line: 2, Msg: `unknown key "proxy"`},
170- },
171- },
172- {
173- name: "unknown section warns and ignores its keys",
174- input: `theme = dark
175-[other]
176-enabled = false
177-[remote]
178-enabled = false
179-`,
180- want: domain.RemoteConfig{Enabled: false, Remember: true, Hosts: []string{}},
181- wantWarnings: []domain.Warning{
182- {Line: 2, Msg: `unknown section "other"`},
183- },
184- wantTheme: domain.ThemeDark,
185- checkTheme: true,
186- },
187- {
188- name: "flat keys preserved outside remote section",
189- input: `theme = light
190-new-tab = alt+t
191-[remote]
192-hosts = ["arch"]
193-`,
194- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{"arch"}},
195- wantTheme: domain.ThemeLight,
196- checkTheme: true,
197- wantBindingEntries: []domain.ConfigEntry{{Key: "new-tab", Value: "alt+t"}},
198- checkBindings: true,
199- },
200- }
201-
202- for _, tt := range tests {
203- t.Run(tt.name, func(t *testing.T) {
204- t.Parallel()
205-
206- cfg, warnings, err := Parse(strings.NewReader(tt.input))
207- require.NoError(t, err)
208- require.Equal(t, tt.want, cfg.Remote)
209- require.Equal(t, tt.wantWarnings, warnings)
210- if tt.checkTheme {
211- require.Equal(t, tt.wantTheme, cfg.Theme)
212- }
213- if tt.checkBindings {
214- require.Equal(t, tt.wantBindingEntries, cfg.BindingEntries)
215- }
216- })
217- }
218-}
219-
220-func TestUpdateRemoteHosts(t *testing.T) {
221- t.Parallel()
222-
223- privateConfigPath := func(t *testing.T) string {
224- t.Helper()
225- return filepath.Join(t.TempDir(), "vev", "config")
226- }
227-
228- t.Run("creates missing config", func(t *testing.T) {
229- t.Parallel()
230- path := privateConfigPath(t)
231-
232- require.NoError(t, UpdateRemoteHosts(path, []string{"arch", "build@mule"}))
233-
234- data, err := os.ReadFile(path)
235- require.NoError(t, err)
236- require.Equal(t, "[remote]\nhosts = [\"arch\", \"build@mule\"]\n", string(data))
237- info, err := os.Stat(path)
238- require.NoError(t, err)
239- require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
240- })
241-
242- t.Run("replaces existing hosts and preserves unrelated text", func(t *testing.T) {
243- t.Parallel()
244- path := privateConfigPath(t)
245- require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
246- original := "# keep me\ntheme = dark\n[remote]\nenabled = false\nhosts = [\"old\"]\nremember = false\nnew-tab = alt+t\n"
247- require.NoError(t, os.WriteFile(path, []byte(original), 0o600))
248-
249- require.NoError(t, UpdateRemoteHosts(path, []string{"arch", "arch", "mule"}))
250-
251- data, err := os.ReadFile(path)
252- require.NoError(t, err)
253- got := string(data)
254- require.Contains(t, got, "# keep me\n")
255- require.Contains(t, got, "theme = dark\n")
256- require.Contains(t, got, "enabled = false\n")
257- require.Contains(t, got, "remember = false\n")
258- require.Contains(t, got, "new-tab = alt+t\n")
259- require.Contains(t, got, "hosts = [\"arch\", \"mule\"]\n")
260- require.NotContains(t, got, "hosts = [\"old\"]")
261- info, err := os.Stat(path)
262- require.NoError(t, err)
263- require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
264- })
265-
266- t.Run("appends remote section when missing", func(t *testing.T) {
267- t.Parallel()
268- path := privateConfigPath(t)
269- require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
270- require.NoError(t, os.WriteFile(path, []byte("theme = light\n"), 0o600))
271-
272- require.NoError(t, UpdateRemoteHosts(path, []string{"arch"}))
273-
274- data, err := os.ReadFile(path)
275- require.NoError(t, err)
276- require.Equal(t, "theme = light\n[remote]\nhosts = [\"arch\"]\n", string(data))
277- })
278-
279- t.Run("appends hosts line when remote section lacks hosts", func(t *testing.T) {
280- t.Parallel()
281- path := privateConfigPath(t)
282- require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
283- require.NoError(t, os.WriteFile(path, []byte("[remote]\nenabled = true\n"), 0o600))
284-
285- require.NoError(t, UpdateRemoteHosts(path, []string{"arch"}))
286-
287- data, err := os.ReadFile(path)
288- require.NoError(t, err)
289- require.Equal(t, "[remote]\nenabled = true\nhosts = [\"arch\"]\n", string(data))
290- })
291-
292- t.Run("preserves CRLF line endings when appending hosts", func(t *testing.T) {
293- t.Parallel()
294- path := privateConfigPath(t)
295- require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
296- original := "[remote]\r\nenabled = true\r\n"
297- require.NoError(t, os.WriteFile(path, []byte(original), 0o600))
298-
299- require.NoError(t, UpdateRemoteHosts(path, []string{"arch"}))
300-
301- data, err := os.ReadFile(path)
302- require.NoError(t, err)
303- require.Equal(t, "[remote]\r\nenabled = true\r\nhosts = [\"arch\"]\r\n", string(data))
304- })
305-
306- t.Run("fails when existing path cannot be read", func(t *testing.T) {
307- t.Parallel()
308- path := privateConfigPath(t)
309- require.NoError(t, os.MkdirAll(path, 0o700))
310-
311- err := UpdateRemoteHosts(path, []string{"arch"})
312- require.Error(t, err)
313- })
314-
315- t.Run("rejects invalid targets without changing existing file", func(t *testing.T) {
316- t.Parallel()
317- path := privateConfigPath(t)
318- require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
319- original := "[remote]\nhosts = [\"arch\"]\n"
320- require.NoError(t, os.WriteFile(path, []byte(original), 0o600))
321-
322- invalid := []struct {
323- name string
324- hosts []string
325- }{
326- {name: "empty", hosts: []string{""}},
327- {name: "whitespace", hosts: []string{" bad"}},
328- {name: "control", hosts: []string{"good", "bad\x00"}},
329- {name: "mixed with valid", hosts: []string{"arch", " "}},
330- }
331- for _, tt := range invalid {
332- t.Run(tt.name, func(t *testing.T) {
333- err := UpdateRemoteHosts(path, tt.hosts)
334- require.Error(t, err)
335- data, readErr := os.ReadFile(path)
336- require.NoError(t, readErr)
337- require.Equal(t, original, string(data))
338- })
339- }
340- })
341-
342- t.Run("rejects invalid targets before creating parent directory", func(t *testing.T) {
343- t.Parallel()
344- path := privateConfigPath(t)
345-
346- err := UpdateRemoteHosts(path, []string{" "})
347- require.Error(t, err)
348- _, statErr := os.Stat(filepath.Dir(path))
349- require.ErrorIs(t, statErr, os.ErrNotExist)
350- })
351-
352- t.Run("replaces last effective hosts assignment", func(t *testing.T) {
353- t.Parallel()
354- path := privateConfigPath(t)
355- require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
356- original := "[remote]\nhosts = [\"first\"]\nenabled = true\nhosts = [\"second\"]\n"
357- require.NoError(t, os.WriteFile(path, []byte(original), 0o600))
358-
359- require.NoError(t, UpdateRemoteHosts(path, []string{"arch"}))
360-
361- data, err := os.ReadFile(path)
362- require.NoError(t, err)
363- got := string(data)
364- require.Contains(t, got, "hosts = [\"first\"]\n")
365- require.Contains(t, got, "hosts = [\"arch\"]\n")
366- require.NotContains(t, got, "hosts = [\"second\"]")
367- cfg, warnings, err := Parse(strings.NewReader(got))
368- require.NoError(t, err)
369- require.Equal(t, []string{"arch"}, cfg.Remote.Hosts)
370- require.Equal(t, []domain.Warning{{Line: 4, Msg: `duplicate key "hosts"`}}, warnings)
371- })
372-
373- t.Run("ignores stale shared tmp permissions", func(t *testing.T) {
374- t.Parallel()
375- path := privateConfigPath(t)
376- dir := filepath.Dir(path)
377- require.NoError(t, safedir.EnsurePrivate(dir))
378- staleTmp := path + ".tmp"
379- require.NoError(t, os.WriteFile(staleTmp, []byte("STALE"), 0o644))
380-
381- require.NoError(t, UpdateRemoteHosts(path, []string{"arch"}))
382-
383- info, err := os.Stat(path)
384- require.NoError(t, err)
385- require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
386- stale, err := os.ReadFile(staleTmp)
387- require.NoError(t, err)
388- require.Equal(t, "STALE", string(stale))
389- data, err := os.ReadFile(path)
390- require.NoError(t, err)
391- require.Equal(t, "[remote]\nhosts = [\"arch\"]\n", string(data))
392- entries, err := os.ReadDir(dir)
393- require.NoError(t, err)
394- for _, entry := range entries {
395- name := entry.Name()
396- if name == "config" || name == "config.tmp" {
397- continue
398- }
399- require.False(t, strings.Contains(name, ".tmp"), "leftover temp %q", name)
400- }
401- })
402-
403- t.Run("concurrent writes to same path leave complete parseable result", func(t *testing.T) {
404- t.Parallel()
405- path := privateConfigPath(t)
406- dir := filepath.Dir(path)
407- require.NoError(t, safedir.EnsurePrivate(dir))
408-
409- const n = 32
410- lists := make([][]string, n)
411- for i := 0; i < n; i++ {
412- lists[i] = []string{"host-a-" + strconv.Itoa(i), "host-b-" + strconv.Itoa(i)}
413- }
414- errCh := make(chan error, n)
415- for i := 0; i < n; i++ {
416- go func(i int) {
417- errCh <- UpdateRemoteHosts(path, lists[i])
418- }(i)
419- }
420- for i := 0; i < n; i++ {
421- require.NoError(t, <-errCh)
422- }
423-
424- data, err := os.ReadFile(path)
425- require.NoError(t, err)
426- cfg, warnings, err := Parse(strings.NewReader(string(data)))
427- require.NoError(t, err)
428- require.Empty(t, warnings)
429- matched := false
430- for _, list := range lists {
431- if reflect.DeepEqual(cfg.Remote.Hosts, list) {
432- matched = true
433- break
434- }
435- }
436- require.True(t, matched, "final hosts %#v must equal one complete submitted list", cfg.Remote.Hosts)
437-
438- info, err := os.Stat(path)
439- require.NoError(t, err)
440- require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
441-
442- entries, err := os.ReadDir(dir)
443- require.NoError(t, err)
444- for _, entry := range entries {
445- name := entry.Name()
446- if name == "config" {
447- continue
448- }
449- require.False(t, strings.Contains(name, "tmp"), "leftover temp %q", name)
450- }
451- })
452-}
@@ -1,452 +0,0 @@
1-package config
2-
3-import (
4- "os"
5- "path/filepath"
6- "reflect"
7- "strconv"
8- "strings"
9- "testing"
10-
11- "github.com/bnema/vev/internal/domain"
12- "github.com/bnema/vev/pkg/safedir"
13- "github.com/stretchr/testify/require"
14-)
15-
16-func TestParseRemote(t *testing.T) {
17- t.Parallel()
18-
19- tests := []struct {
20- name string
21- input string
22- want domain.RemoteConfig
23- wantWarnings []domain.Warning
24- wantTheme domain.ThemeMode
25- checkTheme bool
26- wantBindingEntries []domain.ConfigEntry
27- checkBindings bool
28- }{
29- {
30- name: "defaults when section absent",
31- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
32- },
33- {
34- name: "complete remote block",
35- input: `[remote]
36-enabled = true
37-remember = true
38-hosts = ["arch", "build@mule"]
39-`,
40- want: domain.RemoteConfig{
41- Enabled: true,
42- Remember: true,
43- Hosts: []string{"arch", "build@mule"},
44- },
45- },
46- {
47- name: "enabled false",
48- input: `[remote]
49-enabled = false
50-`,
51- want: domain.RemoteConfig{Enabled: false, Remember: true, Hosts: []string{}},
52- },
53- {
54- name: "remember false",
55- input: `[remote]
56-remember = false
57-`,
58- want: domain.RemoteConfig{Enabled: true, Remember: false, Hosts: []string{}},
59- },
60- {
61- name: "legacy on off rejected and defaults retained",
62- input: `[remote]
63-enabled = off
64-remember = on
65-`,
66- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
67- wantWarnings: []domain.Warning{
68- {Line: 2, Msg: `invalid enabled "off"`},
69- {Line: 3, Msg: `invalid remember "on"`},
70- },
71- },
72- {
73- name: "empty hosts array",
74- input: `[remote]
75-hosts = []
76-`,
77- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
78- },
79- {
80- name: "duplicate keys warn and last valid wins",
81- input: `[remote]
82-enabled = false
83-enabled = true
84-remember = false
85-remember = true
86-hosts = ["arch"]
87-hosts = ["mule"]
88-`,
89- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{"mule"}},
90- wantWarnings: []domain.Warning{
91- {Line: 3, Msg: `duplicate key "enabled"`},
92- {Line: 5, Msg: `duplicate key "remember"`},
93- {Line: 7, Msg: `duplicate key "hosts"`},
94- },
95- },
96- {
97- name: "invalid booleans warn and retain defaults",
98- input: `[remote]
99-enabled = yes
100-remember = True
101-`,
102- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
103- wantWarnings: []domain.Warning{
104- {Line: 2, Msg: `invalid enabled "yes"`},
105- {Line: 3, Msg: `invalid remember "True"`},
106- },
107- },
108- {
109- name: "invalid hosts array retains default",
110- input: `[remote]
111-hosts = [1, "arch"]
112-`,
113- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
114- wantWarnings: []domain.Warning{
115- {Line: 2, Msg: `invalid hosts "[1, \"arch\"]"`},
116- },
117- },
118- {
119- name: "malformed hosts array retains default",
120- input: `[remote]
121-hosts = not-an-array
122-`,
123- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
124- wantWarnings: []domain.Warning{
125- {Line: 2, Msg: `invalid hosts "not-an-array"`},
126- },
127- },
128- {
129- name: "null hosts retains default",
130- input: `[remote]
131-hosts = null
132-`,
133- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
134- wantWarnings: []domain.Warning{
135- {Line: 2, Msg: `invalid hosts "null"`},
136- },
137- },
138- {
139- name: "invalid and duplicate host entries retain default",
140- input: `[remote]
141-hosts = ["arch", "arch", " bad", "ok"]
142-`,
143- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{}},
144- wantWarnings: []domain.Warning{
145- {Line: 2, Msg: `duplicate host "arch"`},
146- {Line: 2, Msg: `invalid remote host " bad": remote host target " bad" has surrounding whitespace`},
147- },
148- },
149- {
150- name: "invalid hosts assignment retains previous value",
151- input: `[remote]
152-hosts = ["arch"]
153-hosts = ["arch", " bad", "ok"]
154-`,
155- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{"arch"}},
156- wantWarnings: []domain.Warning{
157- {Line: 3, Msg: `duplicate key "hosts"`},
158- {Line: 3, Msg: `invalid remote host " bad": remote host target " bad" has surrounding whitespace`},
159- },
160- },
161- {
162- name: "unknown remote key warns",
163- input: `[remote]
164-proxy = true
165-enabled = false
166-`,
167- want: domain.RemoteConfig{Enabled: false, Remember: true, Hosts: []string{}},
168- wantWarnings: []domain.Warning{
169- {Line: 2, Msg: `unknown key "proxy"`},
170- },
171- },
172- {
173- name: "unknown section warns and ignores its keys",
174- input: `theme = dark
175-[other]
176-enabled = false
177-[remote]
178-enabled = false
179-`,
180- want: domain.RemoteConfig{Enabled: false, Remember: true, Hosts: []string{}},
181- wantWarnings: []domain.Warning{
182- {Line: 2, Msg: `unknown section "other"`},
183- },
184- wantTheme: domain.ThemeDark,
185- checkTheme: true,
186- },
187- {
188- name: "flat keys preserved outside remote section",
189- input: `theme = light
190-new-tab = alt+t
191-[remote]
192-hosts = ["arch"]
193-`,
194- want: domain.RemoteConfig{Enabled: true, Remember: true, Hosts: []string{"arch"}},
195- wantTheme: domain.ThemeLight,
196- checkTheme: true,
197- wantBindingEntries: []domain.ConfigEntry{{Key: "new-tab", Value: "alt+t"}},
198- checkBindings: true,
199- },
200- }
201-
202- for _, tt := range tests {
203- t.Run(tt.name, func(t *testing.T) {
204- t.Parallel()
205-
206- cfg, warnings, err := Parse(strings.NewReader(tt.input))
207- require.NoError(t, err)
208- require.Equal(t, tt.want, cfg.Remote)
209- require.Equal(t, tt.wantWarnings, warnings)
210- if tt.checkTheme {
211- require.Equal(t, tt.wantTheme, cfg.Theme)
212- }
213- if tt.checkBindings {
214- require.Equal(t, tt.wantBindingEntries, cfg.BindingEntries)
215- }
216- })
217- }
218-}
219-
220-func TestUpdateRemoteHosts(t *testing.T) {
221- t.Parallel()
222-
223- privateConfigPath := func(t *testing.T) string {
224- t.Helper()
225- return filepath.Join(t.TempDir(), "vev", "config")
226- }
227-
228- t.Run("creates missing config", func(t *testing.T) {
229- t.Parallel()
230- path := privateConfigPath(t)
231-
232- require.NoError(t, UpdateRemoteHosts(path, []string{"arch", "build@mule"}))
233-
234- data, err := os.ReadFile(path)
235- require.NoError(t, err)
236- require.Equal(t, "[remote]\nhosts = [\"arch\", \"build@mule\"]\n", string(data))
237- info, err := os.Stat(path)
238- require.NoError(t, err)
239- require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
240- })
241-
242- t.Run("replaces existing hosts and preserves unrelated text", func(t *testing.T) {
243- t.Parallel()
244- path := privateConfigPath(t)
245- require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
246- original := "# keep me\ntheme = dark\n[remote]\nenabled = false\nhosts = [\"old\"]\nremember = false\nnew-tab = alt+t\n"
247- require.NoError(t, os.WriteFile(path, []byte(original), 0o600))
248-
249- require.NoError(t, UpdateRemoteHosts(path, []string{"arch", "arch", "mule"}))
250-
251- data, err := os.ReadFile(path)
252- require.NoError(t, err)
253- got := string(data)
254- require.Contains(t, got, "# keep me\n")
255- require.Contains(t, got, "theme = dark\n")
256- require.Contains(t, got, "enabled = false\n")
257- require.Contains(t, got, "remember = false\n")
258- require.Contains(t, got, "new-tab = alt+t\n")
259- require.Contains(t, got, "hosts = [\"arch\", \"mule\"]\n")
260- require.NotContains(t, got, "hosts = [\"old\"]")
261- info, err := os.Stat(path)
262- require.NoError(t, err)
263- require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
264- })
265-
266- t.Run("appends remote section when missing", func(t *testing.T) {
267- t.Parallel()
268- path := privateConfigPath(t)
269- require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
270- require.NoError(t, os.WriteFile(path, []byte("theme = light\n"), 0o600))
271-
272- require.NoError(t, UpdateRemoteHosts(path, []string{"arch"}))
273-
274- data, err := os.ReadFile(path)
275- require.NoError(t, err)
276- require.Equal(t, "theme = light\n[remote]\nhosts = [\"arch\"]\n", string(data))
277- })
278-
279- t.Run("appends hosts line when remote section lacks hosts", func(t *testing.T) {
280- t.Parallel()
281- path := privateConfigPath(t)
282- require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
283- require.NoError(t, os.WriteFile(path, []byte("[remote]\nenabled = true\n"), 0o600))
284-
285- require.NoError(t, UpdateRemoteHosts(path, []string{"arch"}))
286-
287- data, err := os.ReadFile(path)
288- require.NoError(t, err)
289- require.Equal(t, "[remote]\nenabled = true\nhosts = [\"arch\"]\n", string(data))
290- })
291-
292- t.Run("preserves CRLF line endings when appending hosts", func(t *testing.T) {
293- t.Parallel()
294- path := privateConfigPath(t)
295- require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
296- original := "[remote]\r\nenabled = true\r\n"
297- require.NoError(t, os.WriteFile(path, []byte(original), 0o600))
298-
299- require.NoError(t, UpdateRemoteHosts(path, []string{"arch"}))
300-
301- data, err := os.ReadFile(path)
302- require.NoError(t, err)
303- require.Equal(t, "[remote]\r\nenabled = true\r\nhosts = [\"arch\"]\r\n", string(data))
304- })
305-
306- t.Run("fails when existing path cannot be read", func(t *testing.T) {
307- t.Parallel()
308- path := privateConfigPath(t)
309- require.NoError(t, os.MkdirAll(path, 0o700))
310-
311- err := UpdateRemoteHosts(path, []string{"arch"})
312- require.Error(t, err)
313- })
314-
315- t.Run("rejects invalid targets without changing existing file", func(t *testing.T) {
316- t.Parallel()
317- path := privateConfigPath(t)
318- require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
319- original := "[remote]\nhosts = [\"arch\"]\n"
320- require.NoError(t, os.WriteFile(path, []byte(original), 0o600))
321-
322- invalid := []struct {
323- name string
324- hosts []string
325- }{
326- {name: "empty", hosts: []string{""}},
327- {name: "whitespace", hosts: []string{" bad"}},
328- {name: "control", hosts: []string{"good", "bad\x00"}},
329- {name: "mixed with valid", hosts: []string{"arch", " "}},
330- }
331- for _, tt := range invalid {
332- t.Run(tt.name, func(t *testing.T) {
333- err := UpdateRemoteHosts(path, tt.hosts)
334- require.Error(t, err)
335- data, readErr := os.ReadFile(path)
336- require.NoError(t, readErr)
337- require.Equal(t, original, string(data))
338- })
339- }
340- })
341-
342- t.Run("rejects invalid targets before creating parent directory", func(t *testing.T) {
343- t.Parallel()
344- path := privateConfigPath(t)
345-
346- err := UpdateRemoteHosts(path, []string{" "})
347- require.Error(t, err)
348- _, statErr := os.Stat(filepath.Dir(path))
349- require.ErrorIs(t, statErr, os.ErrNotExist)
350- })
351-
352- t.Run("replaces last effective hosts assignment", func(t *testing.T) {
353- t.Parallel()
354- path := privateConfigPath(t)
355- require.NoError(t, safedir.EnsurePrivate(filepath.Dir(path)))
356- original := "[remote]\nhosts = [\"first\"]\nenabled = true\nhosts = [\"second\"]\n"
357- require.NoError(t, os.WriteFile(path, []byte(original), 0o600))
358-
359- require.NoError(t, UpdateRemoteHosts(path, []string{"arch"}))
360-
361- data, err := os.ReadFile(path)
362- require.NoError(t, err)
363- got := string(data)
364- require.Contains(t, got, "hosts = [\"first\"]\n")
365- require.Contains(t, got, "hosts = [\"arch\"]\n")
366- require.NotContains(t, got, "hosts = [\"second\"]")
367- cfg, warnings, err := Parse(strings.NewReader(got))
368- require.NoError(t, err)
369- require.Equal(t, []string{"arch"}, cfg.Remote.Hosts)
370- require.Equal(t, []domain.Warning{{Line: 4, Msg: `duplicate key "hosts"`}}, warnings)
371- })
372-
373- t.Run("ignores stale shared tmp permissions", func(t *testing.T) {
374- t.Parallel()
375- path := privateConfigPath(t)
376- dir := filepath.Dir(path)
377- require.NoError(t, safedir.EnsurePrivate(dir))
378- staleTmp := path + ".tmp"
379- require.NoError(t, os.WriteFile(staleTmp, []byte("STALE"), 0o644))
380-
381- require.NoError(t, UpdateRemoteHosts(path, []string{"arch"}))
382-
383- info, err := os.Stat(path)
384- require.NoError(t, err)
385- require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
386- stale, err := os.ReadFile(staleTmp)
387- require.NoError(t, err)
388- require.Equal(t, "STALE", string(stale))
389- data, err := os.ReadFile(path)
390- require.NoError(t, err)
391- require.Equal(t, "[remote]\nhosts = [\"arch\"]\n", string(data))
392- entries, err := os.ReadDir(dir)
393- require.NoError(t, err)
394- for _, entry := range entries {
395- name := entry.Name()
396- if name == "config" || name == "config.tmp" {
397- continue
398- }
399- require.False(t, strings.Contains(name, ".tmp"), "leftover temp %q", name)
400- }
401- })
402-
403- t.Run("concurrent writes to same path leave complete parseable result", func(t *testing.T) {
404- t.Parallel()
405- path := privateConfigPath(t)
406- dir := filepath.Dir(path)
407- require.NoError(t, safedir.EnsurePrivate(dir))
408-
409- const n = 32
410- lists := make([][]string, n)
411- for i := 0; i < n; i++ {
412- lists[i] = []string{"host-a-" + strconv.Itoa(i), "host-b-" + strconv.Itoa(i)}
413- }
414- errCh := make(chan error, n)
415- for i := 0; i < n; i++ {
416- go func(i int) {
417- errCh <- UpdateRemoteHosts(path, lists[i])
418- }(i)
419- }
420- for i := 0; i < n; i++ {
421- require.NoError(t, <-errCh)
422- }
423-
424- data, err := os.ReadFile(path)
425- require.NoError(t, err)
426- cfg, warnings, err := Parse(strings.NewReader(string(data)))
427- require.NoError(t, err)
428- require.Empty(t, warnings)
429- matched := false
430- for _, list := range lists {
431- if reflect.DeepEqual(cfg.Remote.Hosts, list) {
432- matched = true
433- break
434- }
435- }
436- require.True(t, matched, "final hosts %#v must equal one complete submitted list", cfg.Remote.Hosts)
437-
438- info, err := os.Stat(path)
439- require.NoError(t, err)
440- require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
441-
442- entries, err := os.ReadDir(dir)
443- require.NoError(t, err)
444- for _, entry := range entries {
445- name := entry.Name()
446- if name == "config" {
447- continue
448- }
449- require.False(t, strings.Contains(name, "tmp"), "leftover temp %q", name)
450- }
451- })
452-}
M internal/domain/config.go +0 -6
@@ -98,7 +98,6 @@ type Config struct {
9898 Floating FloatingConfig
9999 Nav NavConfig
100100 Tabs TabsConfig
101- Remote RemoteConfig
102101 }
103102
104103 // Warning describes a non-fatal config problem. Parsers and reloaders should
@@ -136,10 +135,5 @@ func Defaults() Config {
136135 Tabs: TabsConfig{
137136 TerminalTitle: true,
138137 },
139- Remote: RemoteConfig{
140- Enabled: true,
141- Remember: true,
142- Hosts: []string{},
143- },
144138 }
145139 }
@@ -98,7 +98,6 @@ type Config struct {
98 Floating FloatingConfig98 Floating FloatingConfig
99 Nav NavConfig99 Nav NavConfig
100 Tabs TabsConfig100 Tabs TabsConfig
101- Remote RemoteConfig
102 }101 }
103 102
104 // Warning describes a non-fatal config problem. Parsers and reloaders should103 // Warning describes a non-fatal config problem. Parsers and reloaders should
@@ -136,10 +135,5 @@ func Defaults() Config {
136 Tabs: TabsConfig{135 Tabs: TabsConfig{
137 TerminalTitle: true,136 TerminalTitle: true,
138 },137 },
139- Remote: RemoteConfig{
140- Enabled: true,
141- Remember: true,
142- Hosts: []string{},
143- },
144 }138 }
145 }139 }
M internal/domain/remote_host.go +0 -7
@@ -6,13 +6,6 @@ import (
66 "unicode"
77 )
88
9-// RemoteConfig contains user-configurable remote host settings.
10-type RemoteConfig struct {
11- Enabled bool
12- Remember bool
13- Hosts []string
14-}
15-
169 // RemoteHost is a merged remote host entry with its source markers.
1710 type RemoteHost struct {
1811 Target string
@@ -6,13 +6,6 @@ import (
6 "unicode"6 "unicode"
7 )7 )
8 8
9-// RemoteConfig contains user-configurable remote host settings.
10-type RemoteConfig struct {
11- Enabled bool
12- Remember bool
13- Hosts []string
14-}
15-
16 // RemoteHost is a merged remote host entry with its source markers.9 // RemoteHost is a merged remote host entry with its source markers.
17 type RemoteHost struct {10 type RemoteHost struct {
18 Target string11 Target string
M internal/domain/remote_host_test.go +0 -16
@@ -71,19 +71,3 @@ func TestRemoteHostUniqueTargets(t *testing.T) {
7171 })
7272 }
7373 }
74-
75-func TestRemoteHostConfigDefaults(t *testing.T) {
76- cfg := Defaults()
77- if !cfg.Remote.Enabled {
78- t.Fatal("Defaults().Remote.Enabled = false, want true")
79- }
80- if !cfg.Remote.Remember {
81- t.Fatal("Defaults().Remote.Remember = false, want true")
82- }
83- if cfg.Remote.Hosts == nil {
84- t.Fatal("Defaults().Remote.Hosts = nil, want empty slice")
85- }
86- if len(cfg.Remote.Hosts) != 0 {
87- t.Fatalf("Defaults().Remote.Hosts = %v, want empty", cfg.Remote.Hosts)
88- }
89-}
@@ -71,19 +71,3 @@ func TestRemoteHostUniqueTargets(t *testing.T) {
71 })71 })
72 }72 }
73 }73 }
74-
75-func TestRemoteHostConfigDefaults(t *testing.T) {
76- cfg := Defaults()
77- if !cfg.Remote.Enabled {
78- t.Fatal("Defaults().Remote.Enabled = false, want true")
79- }
80- if !cfg.Remote.Remember {
81- t.Fatal("Defaults().Remote.Remember = false, want true")
82- }
83- if cfg.Remote.Hosts == nil {
84- t.Fatal("Defaults().Remote.Hosts = nil, want empty slice")
85- }
86- if len(cfg.Remote.Hosts) != 0 {
87- t.Fatalf("Defaults().Remote.Hosts = %v, want empty", cfg.Remote.Hosts)
88- }
89-}
M scripts/debug-remote-attach.sh +1 -1
@@ -80,7 +80,7 @@ diagnostic_files=()
8080 # and common key material labels from bundles that may be shared.
8181 redact() {
8282 sed -E \
83- -e "s|${target//\/\\/\\}|[remote]|g" \
83+ -e "s|${target//\/\\/\\}|[target]|g" \
8484 -e "s|${HOME//\/\\/\\}|[home]|g" \
8585 -e 's/([[:alnum:]_.-]+@)?([[:alnum:]_.-]+\.)+[[:alpha:]]{2,}/[host]/g' \
8686 -e 's/([0-9]{1,3}\.){3}[0-9]{1,3}/[address]/g' \
@@ -80,7 +80,7 @@ diagnostic_files=()
80 # and common key material labels from bundles that may be shared.80 # and common key material labels from bundles that may be shared.
81 redact() {81 redact() {
82 sed -E \82 sed -E \
83- -e "s|${target//\/\\/\\}|[remote]|g" \83+ -e "s|${target//\/\\/\\}|[target]|g" \
84 -e "s|${HOME//\/\\/\\}|[home]|g" \84 -e "s|${HOME//\/\\/\\}|[home]|g" \
85 -e 's/([[:alnum:]_.-]+@)?([[:alnum:]_.-]+\.)+[[:alpha:]]{2,}/[host]/g' \85 -e 's/([[:alnum:]_.-]+@)?([[:alnum:]_.-]+\.)+[[:alpha:]]{2,}/[host]/g' \
86 -e 's/([0-9]{1,3}\.){3}[0-9]{1,3}/[address]/g' \86 -e 's/([0-9]{1,3}\.){3}[0-9]{1,3}/[address]/g' \

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 12

Tags 11

↑↓ moveenter openesc close