detached 4e1cc2a

fix(config): reload a symlinked config when its target changes (#23)

* fix(config): reload a symlinked config when its target changes

Watch also watches the directory of the file the config resolves to, and
moves that watch when the link is retargeted, so dotfiles symlinks reload
on edits and atomic saves.

* fix(config): watch every link of a symlinked config and retry missing targets

The watcher follows the whole symlink chain, keeps watching a dangling
target, and sets its watches up again while a directory is missing.

* test(config): check retargeted links and recreated directories without retried setup

The retarget and directory-recreation steps now happen once, so the
test fails if the watcher does not move or restore its watches.
commit
4e1cc2ae22e3562956c6baa3f37b0449891d4552
author
bnema · · 2026-09-27 14:16 UTC
committer
GitHub · · 2026-09-27 14:16 UTC
parent
509f0eb
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
2 files · +204 -19
M internal/adapters/config/watch.go +68 -19
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "os"
77 "path/filepath"
8+ "slices"
89 "sort"
910 "time"
1011 "unsafe"
@@ -51,10 +52,35 @@ func diff(old, cur map[string]string) []string {
5152 return keys
5253 }
5354
55+// watchedFiles are the files whose changes reload the config: path, then
56+// each symlink it goes through (dotfiles) down to the final file, which may
57+// not exist yet. Symlinked parent directories are followed by inotify
58+// itself; retargeting one is not detected.
59+func watchedFiles(path string) []string {
60+ files := []string{filepath.Clean(path)}
61+ for len(files) <= 40 { // the kernel's symlink limit
62+ cur := files[len(files)-1]
63+ target, err := os.Readlink(cur)
64+ if err != nil {
65+ break
66+ }
67+ if !filepath.IsAbs(target) {
68+ target = filepath.Join(filepath.Dir(cur), target)
69+ }
70+ target = filepath.Clean(target)
71+ if slices.Contains(files, target) {
72+ break // a loop: the config is unreadable anyway
73+ }
74+ files = append(files, target)
75+ }
76+ return files
77+}
78+
5479 // Watch observes the parent directory so atomic file replacements are detected.
55-// If the directory is absent, it retries every two seconds until it appears.
80+// A symlinked config is also watched at its target, and a retargeted link
81+// moves that watch. If the directory is absent, it retries every two seconds
82+// until it appears.
5683 func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log zerowrap.Logger) error {
57- dir, name := filepath.Dir(path), filepath.Base(path)
5884 fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)
5985 if err != nil {
6086 return err
@@ -63,7 +89,12 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log
6389 // IN_MODIFY restarts the debounce on every write, the truncation of an
6490 // in-place save included: the file is never read half written.
6591 const mask = unix.IN_CLOSE_WRITE | unix.IN_MODIFY | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM
66- wd := -1
92+ // names maps each watch to the file names that matter in its directory.
93+ names := map[int32]map[string]bool{}
94+ var files []string
95+ rewatch := true
96+ // retry sets the watches up again while a directory is missing.
97+ var retry time.Time
6798 var pending time.Time
6899 last := loadRaw(path)
69100 buf := make([]byte, 4096)
@@ -71,21 +102,35 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log
71102 if ctx.Err() != nil {
72103 return nil
73104 }
74- if wd < 0 {
75- wd, err = unix.InotifyAddWatch(fd, dir, mask)
76- if errors.Is(err, unix.ENOENT) {
77- select {
78- case <-ctx.Done():
79- return nil
80- case <-time.After(directoryPollInterval):
105+ if !retry.IsZero() && !time.Now().Before(retry) {
106+ rewatch = true
107+ }
108+ if rewatch {
109+ rewatch, retry = false, time.Time{}
110+ for wd := range names {
111+ _, _ = unix.InotifyRmWatch(fd, uint32(wd))
112+ }
113+ clear(names)
114+ files = watchedFiles(path)
115+ for _, file := range files {
116+ wd, err := unix.InotifyAddWatch(fd, filepath.Dir(file), mask)
117+ if errors.Is(err, unix.ENOENT) {
118+ retry = time.Now().Add(directoryPollInterval)
81119 continue
82120 }
83- }
84- if err != nil {
85- return err
121+ if err != nil {
122+ return err
123+ }
124+ if names[int32(wd)] == nil {
125+ names[int32(wd)] = map[string]bool{}
126+ }
127+ names[int32(wd)][filepath.Base(file)] = true
86128 }
87129 }
88130 timeout := 200
131+ if !retry.IsZero() {
132+ timeout = min(timeout, int(time.Until(retry).Milliseconds())+1)
133+ }
89134 if !pending.IsZero() {
90135 remaining := time.Until(pending)
91136 if remaining <= 0 {
@@ -115,9 +160,7 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log
115160 }
116161 continue
117162 }
118- if remaining < 200*time.Millisecond {
119- timeout = int(remaining.Milliseconds()) + 1
120- }
163+ timeout = min(timeout, int(remaining.Milliseconds())+1)
121164 }
122165 fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
123166 _, err = unix.Poll(fds, timeout)
@@ -150,16 +193,22 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log
150193 if offset+size > n {
151194 break
152195 }
153- if event.Mask&unix.IN_IGNORED != 0 {
154- wd = -1
196+ // A removed watch of ours (its directory is gone) is set up
197+ // again; removals we asked for are no longer in names.
198+ if event.Mask&unix.IN_IGNORED != 0 && names[event.Wd] != nil {
199+ rewatch = true
155200 }
156201 if event.Len > 0 {
157202 file := buf[offset+unix.SizeofInotifyEvent : offset+size]
158203 for len(file) > 0 && file[len(file)-1] == 0 {
159204 file = file[:len(file)-1]
160205 }
161- if string(file) == name && event.Mask&mask != 0 {
206+ if names[event.Wd][string(file)] && event.Mask&mask != 0 {
162207 pending = time.Now().Add(100 * time.Millisecond)
208+ // The link may point elsewhere now.
209+ if !slices.Equal(files, watchedFiles(path)) {
210+ rewatch = true
211+ }
163212 }
164213 }
165214 offset += size
@@ -5,6 +5,7 @@ import (
5 "errors"5 "errors"
6 "os"6 "os"
7 "path/filepath"7 "path/filepath"
8+ "slices"
8 "sort"9 "sort"
9 "time"10 "time"
10 "unsafe"11 "unsafe"
@@ -51,10 +52,35 @@ func diff(old, cur map[string]string) []string {
51 return keys52 return keys
52 }53 }
53 54
55+// watchedFiles are the files whose changes reload the config: path, then
56+// each symlink it goes through (dotfiles) down to the final file, which may
57+// not exist yet. Symlinked parent directories are followed by inotify
58+// itself; retargeting one is not detected.
59+func watchedFiles(path string) []string {
60+ files := []string{filepath.Clean(path)}
61+ for len(files) <= 40 { // the kernel's symlink limit
62+ cur := files[len(files)-1]
63+ target, err := os.Readlink(cur)
64+ if err != nil {
65+ break
66+ }
67+ if !filepath.IsAbs(target) {
68+ target = filepath.Join(filepath.Dir(cur), target)
69+ }
70+ target = filepath.Clean(target)
71+ if slices.Contains(files, target) {
72+ break // a loop: the config is unreadable anyway
73+ }
74+ files = append(files, target)
75+ }
76+ return files
77+}
78+
54 // Watch observes the parent directory so atomic file replacements are detected.79 // Watch observes the parent directory so atomic file replacements are detected.
55-// If the directory is absent, it retries every two seconds until it appears.80+// A symlinked config is also watched at its target, and a retargeted link
81+// moves that watch. If the directory is absent, it retries every two seconds
82+// until it appears.
56 func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log zerowrap.Logger) error {83 func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log zerowrap.Logger) error {
57- dir, name := filepath.Dir(path), filepath.Base(path)
58 fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)84 fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)
59 if err != nil {85 if err != nil {
60 return err86 return err
@@ -63,7 +89,12 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log
63 // IN_MODIFY restarts the debounce on every write, the truncation of an89 // IN_MODIFY restarts the debounce on every write, the truncation of an
64 // in-place save included: the file is never read half written.90 // in-place save included: the file is never read half written.
65 const mask = unix.IN_CLOSE_WRITE | unix.IN_MODIFY | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM91 const mask = unix.IN_CLOSE_WRITE | unix.IN_MODIFY | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM
66- wd := -192+ // names maps each watch to the file names that matter in its directory.
93+ names := map[int32]map[string]bool{}
94+ var files []string
95+ rewatch := true
96+ // retry sets the watches up again while a directory is missing.
97+ var retry time.Time
67 var pending time.Time98 var pending time.Time
68 last := loadRaw(path)99 last := loadRaw(path)
69 buf := make([]byte, 4096)100 buf := make([]byte, 4096)
@@ -71,21 +102,35 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log
71 if ctx.Err() != nil {102 if ctx.Err() != nil {
72 return nil103 return nil
73 }104 }
74- if wd < 0 {105+ if !retry.IsZero() && !time.Now().Before(retry) {
75- wd, err = unix.InotifyAddWatch(fd, dir, mask)106+ rewatch = true
76- if errors.Is(err, unix.ENOENT) {107+ }
77- select {108+ if rewatch {
78- case <-ctx.Done():109+ rewatch, retry = false, time.Time{}
79- return nil110+ for wd := range names {
80- case <-time.After(directoryPollInterval):111+ _, _ = unix.InotifyRmWatch(fd, uint32(wd))
112+ }
113+ clear(names)
114+ files = watchedFiles(path)
115+ for _, file := range files {
116+ wd, err := unix.InotifyAddWatch(fd, filepath.Dir(file), mask)
117+ if errors.Is(err, unix.ENOENT) {
118+ retry = time.Now().Add(directoryPollInterval)
81 continue119 continue
82 }120 }
83- }121+ if err != nil {
84- if err != nil {122+ return err
85- return err123+ }
124+ if names[int32(wd)] == nil {
125+ names[int32(wd)] = map[string]bool{}
126+ }
127+ names[int32(wd)][filepath.Base(file)] = true
86 }128 }
87 }129 }
88 timeout := 200130 timeout := 200
131+ if !retry.IsZero() {
132+ timeout = min(timeout, int(time.Until(retry).Milliseconds())+1)
133+ }
89 if !pending.IsZero() {134 if !pending.IsZero() {
90 remaining := time.Until(pending)135 remaining := time.Until(pending)
91 if remaining <= 0 {136 if remaining <= 0 {
@@ -115,9 +160,7 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log
115 }160 }
116 continue161 continue
117 }162 }
118- if remaining < 200*time.Millisecond {163+ timeout = min(timeout, int(remaining.Milliseconds())+1)
119- timeout = int(remaining.Milliseconds()) + 1
120- }
121 }164 }
122 fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}165 fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
123 _, err = unix.Poll(fds, timeout)166 _, err = unix.Poll(fds, timeout)
@@ -150,16 +193,22 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log
150 if offset+size > n {193 if offset+size > n {
151 break194 break
152 }195 }
153- if event.Mask&unix.IN_IGNORED != 0 {196+ // A removed watch of ours (its directory is gone) is set up
154- wd = -1197+ // again; removals we asked for are no longer in names.
198+ if event.Mask&unix.IN_IGNORED != 0 && names[event.Wd] != nil {
199+ rewatch = true
155 }200 }
156 if event.Len > 0 {201 if event.Len > 0 {
157 file := buf[offset+unix.SizeofInotifyEvent : offset+size]202 file := buf[offset+unix.SizeofInotifyEvent : offset+size]
158 for len(file) > 0 && file[len(file)-1] == 0 {203 for len(file) > 0 && file[len(file)-1] == 0 {
159 file = file[:len(file)-1]204 file = file[:len(file)-1]
160 }205 }
161- if string(file) == name && event.Mask&mask != 0 {206+ if names[event.Wd][string(file)] && event.Mask&mask != 0 {
162 pending = time.Now().Add(100 * time.Millisecond)207 pending = time.Now().Add(100 * time.Millisecond)
208+ // The link may point elsewhere now.
209+ if !slices.Equal(files, watchedFiles(path)) {
210+ rewatch = true
211+ }
163 }212 }
164 }213 }
165 offset += size214 offset += size
M internal/adapters/config/watch_test.go +136 -0
@@ -2,8 +2,10 @@ package config
22
33 import (
44 "context"
5+ "fmt"
56 "os"
67 "path/filepath"
8+ "slices"
79 "testing"
810 "time"
911
@@ -112,3 +114,137 @@ func TestWatch(t *testing.T) {
112114 })
113115 }
114116 }
117+
118+// A config symlinked elsewhere (dotfiles), directly or through a chain,
119+// reloads when its target changes, follows a retargeted link, and survives
120+// a target that is missing for a while.
121+func TestWatchSymlink(t *testing.T) {
122+ root := t.TempDir()
123+ dirs := map[string]string{}
124+ for _, d := range []string{"config", "dotfiles", "chain", "other"} {
125+ dirs[d] = filepath.Join(root, d)
126+ if err := os.Mkdir(dirs[d], 0700); err != nil {
127+ t.Fatal(err)
128+ }
129+ }
130+ write := func(file, body string) {
131+ t.Helper()
132+ if err := os.WriteFile(file, []byte(body), 0600); err != nil {
133+ t.Fatal(err)
134+ }
135+ }
136+ link := func(target, at string) {
137+ t.Helper()
138+ tmp := at + ".tmp"
139+ if err := os.Symlink(target, tmp); err != nil {
140+ t.Fatal(err)
141+ }
142+ if err := os.Rename(tmp, at); err != nil {
143+ t.Fatal(err)
144+ }
145+ }
146+ // config/config -> chain/config (relative) -> dotfiles/neferwl.conf
147+ target := filepath.Join(dirs["dotfiles"], "neferwl.conf")
148+ write(target, "background = #000000\n")
149+ middle := filepath.Join(dirs["chain"], "config")
150+ link("../dotfiles/neferwl.conf", middle)
151+ path := filepath.Join(dirs["config"], "config")
152+ link(middle, path)
153+ ctx, cancel := context.WithCancel(context.Background())
154+ out := make(chan ports.ConfigChanged, 8)
155+ done := make(chan error, 1)
156+ old := directoryPollInterval
157+ directoryPollInterval = 20 * time.Millisecond
158+ defer func() { directoryPollInterval = old }()
159+ go func() { done <- Watch(ctx, path, out, logging.For(ctx, "config")) }()
160+ defer func() {
161+ cancel()
162+ if err := <-done; err != nil {
163+ t.Error(err)
164+ }
165+ }()
166+ // until repeats change until the config reloads to want: the watches
167+ // may not be set up yet on a slow machine. Each try changes another key
168+ // too, as the watcher may have read the first try at startup.
169+ tries := 0
170+ until := func(want string, change func(body string)) {
171+ t.Helper()
172+ try := func() {
173+ tries++
174+ change(fmt.Sprintf("background = %s\nborder.width = %d\n", want, tries%32))
175+ }
176+ try()
177+ deadline := time.After(2 * time.Second)
178+ retry := time.NewTicker(300 * time.Millisecond)
179+ defer retry.Stop()
180+ for {
181+ select {
182+ case c := <-out:
183+ if c.Config.Background.Color == want {
184+ return
185+ }
186+ case <-retry.C:
187+ try()
188+ case <-deadline:
189+ t.Fatalf("no config change to %s", want)
190+ }
191+ }
192+ }
193+ atomic := func(file string) func(string) {
194+ return func(body string) {
195+ write(file+".new", body)
196+ if err := os.Rename(file+".new", file); err != nil {
197+ t.Fatal(err)
198+ }
199+ }
200+ }
201+ until("#ff0000", func(body string) { write(target, body) })
202+ until("#00ff00", atomic(target))
203+ // The middle link now points to another file, once: later writes to it
204+ // reload only if the watch moved there.
205+ moved := filepath.Join(dirs["other"], "config")
206+ write(moved, "background = #0000ff\n")
207+ link(moved, middle)
208+ until("#0000ff", func(string) {})
209+ until("#ffffff", func(body string) { write(moved, body) })
210+ // The target disappears with its directory, then comes back once: later
211+ // writes reload only if the missing directory was watched again.
212+ if err := os.RemoveAll(dirs["other"]); err != nil {
213+ t.Fatal(err)
214+ }
215+ time.Sleep(50 * time.Millisecond)
216+ if err := os.Mkdir(dirs["other"], 0700); err != nil {
217+ t.Fatal(err)
218+ }
219+ write(moved, "background = #000001\n")
220+ time.Sleep(100 * time.Millisecond) // past the 20 ms retry
221+ until("#abcdef", func(body string) { write(moved, body) })
222+ // The old target no longer matters.
223+ write(target, "background = #123456\n")
224+ select {
225+ case c := <-out:
226+ t.Fatalf("old target reloaded: %+v", c.Config.Background)
227+ case <-time.After(250 * time.Millisecond):
228+ }
229+}
230+
231+func TestWatchedFiles(t *testing.T) {
232+ dir := t.TempDir()
233+ path := filepath.Join(dir, "config")
234+ if got := watchedFiles(path); !slices.Equal(got, []string{path}) {
235+ t.Fatal(got)
236+ }
237+ // A dangling link still names its target; a loop stops.
238+ if err := os.Symlink("sub/../missing", path); err != nil {
239+ t.Fatal(err)
240+ }
241+ if got := watchedFiles(path); !slices.Equal(got, []string{path, filepath.Join(dir, "missing")}) {
242+ t.Fatal(got)
243+ }
244+ if err := os.Symlink("config", filepath.Join(dir, "missing")); err != nil {
245+ t.Fatal(err)
246+ }
247+ if got := watchedFiles(path); !slices.Equal(got, []string{path, filepath.Join(dir, "missing")}) {
248+ t.Fatal(got)
249+ }
250+}
@@ -2,8 +2,10 @@ package config
2 2
3 import (3 import (
4 "context"4 "context"
5+ "fmt"
5 "os"6 "os"
6 "path/filepath"7 "path/filepath"
8+ "slices"
7 "testing"9 "testing"
8 "time"10 "time"
9 11
@@ -112,3 +114,137 @@ func TestWatch(t *testing.T) {
112 })114 })
113 }115 }
114 }116 }
117+
118+// A config symlinked elsewhere (dotfiles), directly or through a chain,
119+// reloads when its target changes, follows a retargeted link, and survives
120+// a target that is missing for a while.
121+func TestWatchSymlink(t *testing.T) {
122+ root := t.TempDir()
123+ dirs := map[string]string{}
124+ for _, d := range []string{"config", "dotfiles", "chain", "other"} {
125+ dirs[d] = filepath.Join(root, d)
126+ if err := os.Mkdir(dirs[d], 0700); err != nil {
127+ t.Fatal(err)
128+ }
129+ }
130+ write := func(file, body string) {
131+ t.Helper()
132+ if err := os.WriteFile(file, []byte(body), 0600); err != nil {
133+ t.Fatal(err)
134+ }
135+ }
136+ link := func(target, at string) {
137+ t.Helper()
138+ tmp := at + ".tmp"
139+ if err := os.Symlink(target, tmp); err != nil {
140+ t.Fatal(err)
141+ }
142+ if err := os.Rename(tmp, at); err != nil {
143+ t.Fatal(err)
144+ }
145+ }
146+ // config/config -> chain/config (relative) -> dotfiles/neferwl.conf
147+ target := filepath.Join(dirs["dotfiles"], "neferwl.conf")
148+ write(target, "background = #000000\n")
149+ middle := filepath.Join(dirs["chain"], "config")
150+ link("../dotfiles/neferwl.conf", middle)
151+ path := filepath.Join(dirs["config"], "config")
152+ link(middle, path)
153+ ctx, cancel := context.WithCancel(context.Background())
154+ out := make(chan ports.ConfigChanged, 8)
155+ done := make(chan error, 1)
156+ old := directoryPollInterval
157+ directoryPollInterval = 20 * time.Millisecond
158+ defer func() { directoryPollInterval = old }()
159+ go func() { done <- Watch(ctx, path, out, logging.For(ctx, "config")) }()
160+ defer func() {
161+ cancel()
162+ if err := <-done; err != nil {
163+ t.Error(err)
164+ }
165+ }()
166+ // until repeats change until the config reloads to want: the watches
167+ // may not be set up yet on a slow machine. Each try changes another key
168+ // too, as the watcher may have read the first try at startup.
169+ tries := 0
170+ until := func(want string, change func(body string)) {
171+ t.Helper()
172+ try := func() {
173+ tries++
174+ change(fmt.Sprintf("background = %s\nborder.width = %d\n", want, tries%32))
175+ }
176+ try()
177+ deadline := time.After(2 * time.Second)
178+ retry := time.NewTicker(300 * time.Millisecond)
179+ defer retry.Stop()
180+ for {
181+ select {
182+ case c := <-out:
183+ if c.Config.Background.Color == want {
184+ return
185+ }
186+ case <-retry.C:
187+ try()
188+ case <-deadline:
189+ t.Fatalf("no config change to %s", want)
190+ }
191+ }
192+ }
193+ atomic := func(file string) func(string) {
194+ return func(body string) {
195+ write(file+".new", body)
196+ if err := os.Rename(file+".new", file); err != nil {
197+ t.Fatal(err)
198+ }
199+ }
200+ }
201+ until("#ff0000", func(body string) { write(target, body) })
202+ until("#00ff00", atomic(target))
203+ // The middle link now points to another file, once: later writes to it
204+ // reload only if the watch moved there.
205+ moved := filepath.Join(dirs["other"], "config")
206+ write(moved, "background = #0000ff\n")
207+ link(moved, middle)
208+ until("#0000ff", func(string) {})
209+ until("#ffffff", func(body string) { write(moved, body) })
210+ // The target disappears with its directory, then comes back once: later
211+ // writes reload only if the missing directory was watched again.
212+ if err := os.RemoveAll(dirs["other"]); err != nil {
213+ t.Fatal(err)
214+ }
215+ time.Sleep(50 * time.Millisecond)
216+ if err := os.Mkdir(dirs["other"], 0700); err != nil {
217+ t.Fatal(err)
218+ }
219+ write(moved, "background = #000001\n")
220+ time.Sleep(100 * time.Millisecond) // past the 20 ms retry
221+ until("#abcdef", func(body string) { write(moved, body) })
222+ // The old target no longer matters.
223+ write(target, "background = #123456\n")
224+ select {
225+ case c := <-out:
226+ t.Fatalf("old target reloaded: %+v", c.Config.Background)
227+ case <-time.After(250 * time.Millisecond):
228+ }
229+}
230+
231+func TestWatchedFiles(t *testing.T) {
232+ dir := t.TempDir()
233+ path := filepath.Join(dir, "config")
234+ if got := watchedFiles(path); !slices.Equal(got, []string{path}) {
235+ t.Fatal(got)
236+ }
237+ // A dangling link still names its target; a loop stops.
238+ if err := os.Symlink("sub/../missing", path); err != nil {
239+ t.Fatal(err)
240+ }
241+ if got := watchedFiles(path); !slices.Equal(got, []string{path, filepath.Join(dir, "missing")}) {
242+ t.Fatal(got)
243+ }
244+ if err := os.Symlink("config", filepath.Join(dir, "missing")); err != nil {
245+ t.Fatal(err)
246+ }
247+ if got := watchedFiles(path); !slices.Equal(got, []string{path, filepath.Join(dir, "missing")}) {
248+ t.Fatal(got)
249+ }
250+}

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 25

Tags 10

↑↓ moveenter openesc close