detached b4521f0

watch.go RawHistoryFolder

1package kvconf
2
3import (
4	"context"
5	"io/fs"
6	"maps"
7	"os"
8	"path/filepath"
9	"runtime"
10	"slices"
11	"strings"
12	"time"
13)
14
15// DefaultDebounce is the quiet time Watch waits for after a change.
16const DefaultDebounce = 100 * time.Millisecond
17
18// retryMissing is how often a missing directory is looked for again, in
19// case its creation was not seen.
20const retryMissing = 2 * time.Second
21
22// WatchOptions controls Watch.
23type WatchOptions struct {
24	// Debounce is the quiet time after a change before it is reported:
25	// every write restarts it, so a file is never read half written. 0
26	// means DefaultDebounce; a negative value reports each change at once.
27	Debounce time.Duration
28	// Dir watches every file directly inside the path, a directory, instead
29	// of the path itself. Hidden files (".swp") and backups ("~") are
30	// ignored and subdirectories are not watched.
31	Dir bool
32	// Poll, when positive, checks the files at this interval instead of
33	// using the native OS notifications. Native watching also falls back to
34	// polling every second where it is not available.
35	Poll time.Duration
36	// Ready, when not nil, is closed once the watches are installed: a
37	// change after that point is reported.
38	Ready chan<- struct{}
39}
40
41func (o *WatchOptions) debounce() time.Duration {
42	switch {
43	case o.Debounce == 0:
44		return DefaultDebounce
45	case o.Debounce < 0:
46		return 0
47	}
48	return o.Debounce
49}
50
51// Watch reports on out each time the file at path changes, is created,
52// replaced or removed, once it has been quiet for the debounce time. It
53// watches the parent directory, so editors that save by renaming a new file
54// over the old one are seen. It follows symlinks and watches each link of
55// the chain, so a dotfile-managed config is seen at its target and a
56// retargeted link is followed. A missing directory is waited for.
57//
58// The send never blocks: give out a buffer of 1, and a pending value already
59// means "changed". Watch returns nil when ctx ends, or an error when the OS
60// refuses to watch.
61func Watch(ctx context.Context, path string, opts WatchOptions, out chan<- struct{}) error {
62	path, err := filepath.Abs(path)
63	if err != nil {
64		return err
65	}
66	var b backend
67	if opts.Poll > 0 {
68		b = newPoller(opts.Poll)
69	} else if b, err = newNative(); err != nil {
70		return err
71	}
72	defer b.close() //nolint:errcheck // nothing to report once watching ends
73	stop := context.AfterFunc(ctx, b.wake)
74	defer stop()
75
76	w := &watcher{path: path, dir: opts.Dir, stamps: map[string]stamp{}}
77	missing, _, err := w.rewatchChanged(b)
78	if err != nil {
79		return err
80	}
81	if opts.Ready != nil {
82		close(opts.Ready)
83	}
84	debounce := opts.debounce()
85	var pending, retry time.Time
86	if missing {
87		retry = time.Now().Add(retryMissing)
88	}
89	var evs []event
90	for ctx.Err() == nil {
91		timeout := time.Duration(-1)
92		for _, t := range [...]time.Time{pending, retry} {
93			if !t.IsZero() {
94				if d := max(time.Until(t), 0); timeout < 0 || d < timeout {
95					timeout = d
96				}
97			}
98		}
99		if evs, err = b.wait(timeout, evs[:0]); err != nil {
100			return err
101		}
102		relevant, rewatch, recheck := w.classify(evs)
103		now := time.Now()
104		if !retry.IsZero() && !now.Before(retry) {
105			rewatch, retry = true, time.Time{}
106		}
107		if recheck && retry.IsZero() {
108			// A removed directory may linger, unreported, until its last
109			// handle closes (Windows): look once more later.
110			retry = now.Add(retryMissing)
111		}
112		if rewatch {
113			changed := false
114			if missing, changed, err = w.rewatchChanged(b); err != nil {
115				return err
116			}
117			relevant = relevant || changed
118			if missing && retry.IsZero() {
119				retry = now.Add(retryMissing)
120			}
121		}
122		if relevant {
123			pending = now.Add(debounce)
124		}
125		if !pending.IsZero() && !now.Before(pending) {
126			pending = time.Time{}
127			// Record what is reported, so a later rewatch does not report
128			// the same change again.
129			for _, t := range w.targets {
130				w.stampsChanged(t)
131			}
132			select {
133			case out <- struct{}{}:
134			default:
135			}
136		}
137	}
138	return nil
139}
140
141// backend is the native notification API of one OS.
142type backend interface {
143	// set replaces the watched directories with targets. It reports
144	// whether a directory vanished before it could be watched.
145	set(targets []*target) (missing bool, err error)
146	// wait appends events to evs. It returns after events arrive, after
147	// timeout (forever when negative), or after wake.
148	wait(timeout time.Duration, evs []event) ([]event, error)
149	// wake makes the current or next wait return. Safe for concurrent use.
150	wake()
151	close() error
152}
153
154// target is one watched directory and the names that matter in it.
155type target struct {
156	dir string
157	// names are files whose changes are reported.
158	names []string
159	// all reports every visible file (Dir mode).
160	all bool
161	// next is the missing child on the way to the wanted directory: its
162	// creation means the watches must move down.
163	next string
164	// paths are dir joined with names and next, computed once.
165	paths []string
166	// moved lists watched child directories whose rename must move the
167	// watches (Windows does not report a watched directory's own rename).
168	moved []string
169}
170
171// stampPaths lists the full paths whose stamps matter.
172func (t *target) stampPaths() []string {
173	if t.paths == nil {
174		for _, n := range t.names {
175			t.paths = append(t.paths, filepath.Join(t.dir, n))
176		}
177		if t.next != "" {
178			t.paths = append(t.paths, filepath.Join(t.dir, t.next))
179		}
180	}
181	return t.paths
182}
183
184func (t *target) has(name string) bool {
185	return slices.Contains(t.names, name) || t.all && visible(name)
186}
187
188// event is a change in a watched directory. An empty name means "something
189// changed in dir" (kqueue, polling, overflow); an empty dir means anything
190// may have changed, or a watch was lost.
191type event struct {
192	dir, name string
193	// moved: the name was created, removed or renamed, so a link may now
194	// point elsewhere. A plain write leaves links alone.
195	moved bool
196}
197
198func visible(name string) bool {
199	return name != "" && name[0] != '.' && name[len(name)-1] != '~'
200}
201
202type watcher struct {
203	path    string
204	dir     bool
205	targets []*target
206	chain   []string
207	stamps  map[string]stamp
208}
209
210// chain lists the paths whose change can change what path names: path
211// itself, every symlink met while resolving it, in the file name or in a
212// parent directory (GNU stow links whole directories), and the final file.
213// The resolved file is last.
214func chain(path string) []string {
215	path = filepath.Clean(path)
216	out := []string{path}
217	resolved, links := path, 0
218	for {
219		next, link, ok := resolveOnce(resolved)
220		if !ok {
221			break
222		}
223		if links++; links > 40 { // the kernel's symlink limit
224			break
225		}
226		if !slices.Contains(out, link) {
227			out = append(out, link)
228		}
229		if slices.Contains(out, next) && next != path {
230			break // a loop: the file is unreadable anyway
231		}
232		resolved = next
233	}
234	if resolved != path {
235		out = append(slices.DeleteFunc(out, func(p string) bool { return p == resolved }), resolved)
236	}
237	return out
238}
239
240// resolveOnce replaces the first symlink in path, from the root down, by its
241// target. link is the symlink's own path.
242func resolveOnce(path string) (next, link string, ok bool) {
243	vol := filepath.VolumeName(path)
244	rest := path[len(vol):]
245	cur := vol
246	for rest != "" {
247		sep := strings.IndexByte(rest, filepath.Separator)
248		var part string
249		if sep < 0 {
250			part, rest = rest, ""
251		} else {
252			part, rest = rest[:sep], rest[sep+1:]
253		}
254		if part == "" {
255			cur += string(filepath.Separator)
256			continue
257		}
258		cur = filepath.Join(cur, part)
259		target, err := os.Readlink(cur)
260		if err != nil {
261			continue // not a link, or missing: later parts cannot resolve
262		}
263		if !filepath.IsAbs(target) {
264			target = filepath.Join(filepath.Dir(cur), target)
265		}
266		return filepath.Join(target, rest), cur, true
267	}
268	return path, "", false
269}
270
271func isDir(path string) bool {
272	info, err := os.Stat(path)
273	return err == nil && info.IsDir()
274}
275
276// plan computes the targets for the current state of the file system.
277func (w *watcher) plan() (targets []*target, files []string) {
278	byDir := map[string]*target{}
279	get := func(dir string) *target {
280		t := byDir[dir]
281		if t == nil {
282			t = &target{dir: dir}
283			byDir[dir] = t
284			targets = append(targets, t)
285		}
286		return t
287	}
288	add := func(dir, name string, all bool) {
289		if !isDir(dir) {
290			// Watch the closest existing ancestor for the next missing child.
291			child := dir
292			for parent := filepath.Dir(child); ; parent = filepath.Dir(child) {
293				if parent == child {
294					return // nothing exists, not even the root
295				}
296				if isDir(parent) {
297					get(parent).next = normName(filepath.Base(child))
298					return
299				}
300				child = parent
301			}
302		}
303		t := get(dir)
304		t.all = t.all || all
305		if name != "" && !slices.Contains(t.names, name) {
306			t.names = append(t.names, name)
307		}
308	}
309	if w.dir {
310		files = []string{w.path}
311		add(w.path, "", true)
312	} else {
313		files = chain(w.path)
314		for _, f := range files {
315			add(filepath.Dir(f), normName(filepath.Base(f)), false)
316		}
317	}
318	if runtime.GOOS == "windows" {
319		for _, t := range slices.Clone(targets) {
320			if parent := filepath.Dir(t.dir); parent != t.dir && isDir(parent) {
321				p := get(parent)
322				p.moved = append(p.moved, normName(filepath.Base(t.dir)))
323			}
324		}
325	}
326	return targets, files
327}
328
329// rewatchChanged installs the watches again and reports whether the watched
330// files changed meanwhile: a link was retargeted, a missing directory
331// appeared, or a file changed while it was not watched.
332func (w *watcher) rewatchChanged(b backend) (missing, changed bool, err error) {
333	targets, files := w.plan()
334	changed = w.chain != nil && !slices.Equal(files, w.chain) || !sameTargets(targets, w.targets)
335	w.targets, w.chain = targets, files
336	missing, err = b.set(targets)
337	for _, t := range targets {
338		missing = missing || t.next != ""
339	}
340	old := w.stamps
341	w.stamps = make(map[string]stamp, len(old))
342	for _, t := range targets {
343		w.stampsChanged(t)
344	}
345	changed = changed || !maps.Equal(old, w.stamps)
346	return missing, changed, err
347}
348
349func sameTargets(a, b []*target) bool {
350	return slices.EqualFunc(a, b, func(x, y *target) bool {
351		return x.dir == y.dir && x.all == y.all && x.next == y.next && slices.Equal(x.names, y.names) &&
352			slices.Equal(x.moved, y.moved)
353	})
354}
355
356// classify reports whether evs touch a watched file, whether the watches
357// must be installed again, and whether to look again later because a
358// watch was lost.
359func (w *watcher) classify(evs []event) (relevant, rewatch, recheck bool) {
360	relinked := false
361	for _, ev := range evs {
362		if ev.dir == "" {
363			relevant, rewatch, recheck = true, true, true
364			continue
365		}
366		t := w.find(ev.dir)
367		if t == nil {
368			continue
369		}
370		if ev.name == "" {
371			if w.stampsChanged(t) {
372				// kqueue watches files by descriptor: a replaced file needs a new one.
373				relevant, rewatch = true, true
374			}
375			continue
376		}
377		name := normName(ev.name)
378		if t.next != "" && name == t.next || ev.moved && slices.Contains(t.moved, name) {
379			relevant, rewatch = true, true
380		}
381		if ev.moved && !rewatch && !isDir(t.dir) {
382			// Some systems (Windows, network file systems) do not report a
383			// watched directory that was removed: look again later.
384			relevant, rewatch, recheck = true, true, true
385			continue
386		}
387		if t.has(name) {
388			if t.all && ev.moved && isDir(filepath.Join(t.dir, name)) {
389				continue // Dir mode ignores subdirectories
390			}
391			relevant = true
392			relinked = relinked || ev.moved
393			// Windows reports a removed directory only once its last handle,
394			// ours, closes: after a removal inside it, look again later. The
395			// stamps keep that second look from reporting the change twice.
396			recheck = recheck || ev.moved && runtime.GOOS == "windows"
397		}
398	}
399	if relinked && !rewatch && !w.dir && !slices.Equal(chain(w.path), w.chain) {
400		rewatch = true // a link points elsewhere now
401	}
402	return relevant, rewatch, recheck
403}
404
405func (w *watcher) find(dir string) *target {
406	for _, t := range w.targets {
407		if t.dir == dir {
408			return t
409		}
410	}
411	return nil
412}
413
414// stamp identifies a version of a file without reading it.
415type stamp struct {
416	size  int64
417	mtime int64
418	mode  fs.FileMode
419	id    uint64
420}
421
422func stampOf(info fs.FileInfo) stamp {
423	return stamp{size: info.Size(), mtime: info.ModTime().UnixNano(), mode: info.Mode(), id: fileID(info)}
424}
425
426// stampsChanged refreshes the stamps of the files that matter in t and
427// reports whether any was created, changed or removed.
428func (w *watcher) stampsChanged(t *target) bool {
429	changed := false
430	check := func(key string, info fs.FileInfo) {
431		old, had := w.stamps[key]
432		if info == nil {
433			if had {
434				delete(w.stamps, key)
435				changed = true
436			}
437			return
438		}
439		if s := stampOf(info); !had || old != s {
440			w.stamps[key] = s
441			changed = true
442		}
443	}
444	paths := t.stampPaths()
445	for _, p := range paths {
446		info, err := os.Lstat(p)
447		if err != nil {
448			info = nil
449		}
450		check(p, info)
451	}
452	if t.all {
453		entries, _ := os.ReadDir(t.dir)
454		live := map[string]bool{}
455		for _, e := range entries {
456			if !visible(e.Name()) || e.IsDir() {
457				continue
458			}
459			info, err := e.Info()
460			if err != nil {
461				continue
462			}
463			key := filepath.Join(t.dir, e.Name())
464			live[key] = true
465			check(key, info)
466		}
467		for key := range w.stamps {
468			if filepath.Dir(key) == t.dir && !live[key] && !slices.Contains(paths, key) {
469				delete(w.stamps, key)
470				changed = true
471			}
472		}
473	}
474	return changed
475}
476
477// normName folds the case of names on Windows, whose file names ignore it.
478func normName(name string) string {
479	if runtime.GOOS == "windows" {
480		return strings.ToLower(name)
481	}
482	return name
483}

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 1

Tags 3

↑↓ moveenter openesc close