v0.1.0 3b1b6ef

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

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