v0.6.1 3d41bc5

control_test.go RawHistoryFolder

1package control
2
3import (
4	"context"
5	"errors"
6	"io"
7	"net"
8	"os"
9	"os/exec"
10	"path/filepath"
11	"runtime"
12	"strings"
13	"sync"
14	"testing"
15	"time"
16
17	"github.com/stretchr/testify/assert"
18	"github.com/stretchr/testify/require"
19	"golang.org/x/sys/unix"
20)
21
22const helperEnv = "NEFERCAP_CONTROL_TEST_HELPER"
23
24// TestMain doubles as a helper process: with helperEnv set, the test binary
25// runs a real recording owner instead of the tests.
26func TestMain(m *testing.M) {
27	switch os.Getenv(helperEnv) {
28	case "":
29		os.Exit(m.Run())
30	case "owner":
31		os.Exit(helperOwner())
32	}
33	os.Exit(64)
34}
35
36func helperOwner() int {
37	s, err := Listen()
38	if errors.Is(err, ErrAlreadyRecording) {
39		return 3
40	}
41	if err != nil {
42		return 4
43	}
44	os.Stdout.WriteString("ready\n")
45	if os.Getenv(helperEnv+"_HANG") != "" {
46		select {} // killed by the test
47	}
48	<-s.Requests()
49	if s.Close() != nil {
50		return 5
51	}
52	return 0
53}
54
55// setup points XDG_RUNTIME_DIR at a fresh private temp directory.
56func setup(t *testing.T) string {
57	t.Helper()
58	rd := t.TempDir()
59	t.Setenv("XDG_RUNTIME_DIR", rd)
60	return rd
61}
62
63func listenT(t *testing.T) *Server {
64	t.Helper()
65	s, err := Listen()
66	require.NoError(t, err)
67	t.Cleanup(func() { s.Close() })
68	return s
69}
70
71func ctx(t *testing.T) context.Context {
72	t.Helper()
73	c, cancel := context.WithTimeout(context.Background(), 10*time.Second)
74	t.Cleanup(cancel)
75	return c
76}
77
78func sockPath(rd string) string { return filepath.Join(rd, dirName, sockName) }
79
80func dial(t *testing.T, rd string) *net.UnixConn {
81	t.Helper()
82	c, err := net.Dial("unix", sockPath(rd))
83	require.NoError(t, err)
84	t.Cleanup(func() { c.Close() })
85	return c.(*net.UnixConn)
86}
87
88func startHelper(t *testing.T, extra ...string) (*exec.Cmd, io.ReadCloser) {
89	t.Helper()
90	cmd := exec.Command(os.Args[0], "-test.run=^$")
91	cmd.Env = append(os.Environ(), append([]string{helperEnv + "=owner"}, extra...)...)
92	out, err := cmd.StdoutPipe()
93	require.NoError(t, err)
94	require.NoError(t, cmd.Start())
95	t.Cleanup(func() { cmd.Process.Kill(); cmd.Wait() })
96	return cmd, out
97}
98
99func waitReady(t *testing.T, out io.Reader) {
100	t.Helper()
101	buf := make([]byte, len("ready\n"))
102	_, err := io.ReadFull(out, buf)
103	require.NoError(t, err)
104	require.Equal(t, "ready\n", string(buf))
105}
106
107func TestStopAndStatusRoundTrip(t *testing.T) {
108	setup(t)
109	s := listenT(t)
110
111	ok, err := Query(ctx(t))
112	require.NoError(t, err)
113	assert.True(t, ok)
114	select {
115	case r := <-s.Requests():
116		t.Fatalf("status was delivered as %v", r)
117	default:
118	}
119
120	ok, err = Stop(ctx(t))
121	require.NoError(t, err)
122	assert.True(t, ok)
123	select {
124	case r := <-s.Requests():
125		assert.Equal(t, RequestStop, r)
126	case <-time.After(5 * time.Second):
127		t.Fatal("stop request not delivered")
128	}
129}
130
131func TestNoServer(t *testing.T) {
132	rd := setup(t)
133	ok, err := Stop(ctx(t))
134	assert.NoError(t, err)
135	assert.False(t, ok)
136	ok, err = Query(ctx(t))
137	assert.NoError(t, err)
138	assert.False(t, ok)
139	// Clients never create anything.
140	_, err = os.Lstat(filepath.Join(rd, dirName))
141	assert.True(t, os.IsNotExist(err))
142}
143
144func TestRuntimeDirValidation(t *testing.T) {
145	for _, v := range []string{"", "relative/dir", strings.Repeat("/a", maxPath)} {
146		t.Setenv("XDG_RUNTIME_DIR", v)
147		_, err := Listen()
148		assert.ErrorIs(t, err, ErrNoRuntimeDir, "%q", v)
149		_, err = Stop(ctx(t))
150		assert.ErrorIs(t, err, ErrNoRuntimeDir)
151		_, err = Query(ctx(t))
152		assert.ErrorIs(t, err, ErrNoRuntimeDir)
153	}
154}
155
156func TestListenRejectsRawRuntimeDir(t *testing.T) {
157	for _, v := range []string{"", "relative", "/tmp/a\x00b"} {
158		_, err := listen(v, uint32(os.Geteuid()))
159		assert.ErrorIs(t, err, ErrNoRuntimeDir, "%q", v)
160	}
161}
162
163func TestPermissions(t *testing.T) {
164	rd := setup(t)
165	old := unix.Umask(0o000)
166	defer unix.Umask(old)
167	listenT(t)
168
169	for path, mode := range map[string]os.FileMode{
170		filepath.Join(rd, dirName):           0o700,
171		sockPath(rd):                         0o600,
172		filepath.Join(rd, dirName, lockName): 0o600,
173	} {
174		fi, err := os.Lstat(path)
175		require.NoError(t, err)
176		assert.Equal(t, mode, fi.Mode().Perm(), filepath.Base(path))
177	}
178	fi, err := os.Lstat(sockPath(rd))
179	require.NoError(t, err)
180	assert.NotZero(t, fi.Mode()&os.ModeSocket)
181}
182
183func TestSingleInstance(t *testing.T) {
184	rd := setup(t)
185	first := listenT(t)
186
187	second, err := Listen()
188	require.ErrorIs(t, err, ErrAlreadyRecording)
189	assert.Nil(t, second)
190
191	// The failed attempt must not have disturbed the live socket.
192	ok, err := Query(ctx(t))
193	require.NoError(t, err)
194	assert.True(t, ok)
195
196	require.NoError(t, first.Close())
197	_, err = os.Lstat(sockPath(rd))
198	assert.True(t, os.IsNotExist(err))
199
200	third, err := Listen()
201	require.NoError(t, err)
202	require.NoError(t, third.Close())
203}
204
205func TestSingleInstanceAcrossProcesses(t *testing.T) {
206	rd := setup(t)
207	cmd, out := startHelper(t)
208	waitReady(t, out)
209
210	// The other process holds the lock, so we neither serve nor unlink.
211	_, err := Listen()
212	require.ErrorIs(t, err, ErrAlreadyRecording)
213	_, err = os.Lstat(sockPath(rd))
214	require.NoError(t, err)
215
216	second := exec.Command(os.Args[0], "-test.run=^$")
217	second.Env = append(os.Environ(), helperEnv+"=owner")
218	err = second.Run()
219	var ee *exec.ExitError
220	require.ErrorAs(t, err, &ee)
221	assert.Equal(t, 3, ee.ExitCode())
222
223	ok, err := Stop(ctx(t))
224	require.NoError(t, err)
225	assert.True(t, ok)
226	require.NoError(t, cmd.Wait())
227	_, err = os.Lstat(sockPath(rd))
228	assert.True(t, os.IsNotExist(err), "owner removes its socket on close")
229}
230
231func TestStaleSocketAfterKilledOwner(t *testing.T) {
232	rd := setup(t)
233	cmd, out := startHelper(t, helperEnv+"_HANG=1")
234	waitReady(t, out)
235	require.NoError(t, cmd.Process.Kill())
236	_ = cmd.Wait()
237
238	_, err := os.Lstat(sockPath(rd))
239	require.NoError(t, err, "killed owner leaves a stale socket")
240
241	// Clients report "not recording" and leave the stale inode alone.
242	ok, err := Query(ctx(t))
243	assert.NoError(t, err)
244	assert.False(t, ok)
245	ok, err = Stop(ctx(t))
246	assert.NoError(t, err)
247	assert.False(t, ok)
248	_, err = os.Lstat(sockPath(rd))
249	require.NoError(t, err, "clients must not unlink")
250
251	// The next lock holder replaces it.
252	s := listenT(t)
253	ok, err = Query(ctx(t))
254	require.NoError(t, err)
255	assert.True(t, ok)
256	require.NoError(t, s.Close())
257}
258
259func TestStaleLeftoverWithoutLockedOwner(t *testing.T) {
260	rd := setup(t)
261	s, err := Listen()
262	require.NoError(t, err)
263	// Simulate a crash: drop the lock but leave the socket inode behind.
264	require.NoError(t, unix.Close(s.lockFD))
265	s.lockFD = -1
266	require.NoError(t, s.ln.Close())
267	_, err = os.Lstat(sockPath(rd))
268	require.NoError(t, err)
269
270	s2 := listenT(t)
271	ok, err := Query(ctx(t))
272	require.NoError(t, err)
273	assert.True(t, ok)
274	require.NoError(t, s2.Close())
275}
276
277func TestListenRefusesForeignObjects(t *testing.T) {
278	t.Run("non-socket at socket path", func(t *testing.T) {
279		rd := setup(t)
280		require.NoError(t, os.Mkdir(filepath.Join(rd, dirName), 0o700))
281		require.NoError(t, os.WriteFile(sockPath(rd), []byte("keep"), 0o600))
282		_, err := Listen()
283		require.ErrorIs(t, err, ErrInsecure)
284		b, err := os.ReadFile(sockPath(rd))
285		require.NoError(t, err)
286		assert.Equal(t, "keep", string(b))
287	})
288	t.Run("symlink at socket path", func(t *testing.T) {
289		rd := setup(t)
290		require.NoError(t, os.Mkdir(filepath.Join(rd, dirName), 0o700))
291		target := filepath.Join(rd, "target")
292		require.NoError(t, os.WriteFile(target, []byte("keep"), 0o600))
293		require.NoError(t, os.Symlink(target, sockPath(rd)))
294		_, err := Listen()
295		require.ErrorIs(t, err, ErrInsecure)
296		_, err = os.Stat(target)
297		assert.NoError(t, err)
298	})
299	t.Run("symlinked lock file", func(t *testing.T) {
300		rd := setup(t)
301		require.NoError(t, os.Mkdir(filepath.Join(rd, dirName), 0o700))
302		target := filepath.Join(rd, "target")
303		require.NoError(t, os.WriteFile(target, nil, 0o644))
304		require.NoError(t, os.Symlink(target, filepath.Join(rd, dirName, lockName)))
305		_, err := Listen()
306		require.Error(t, err)
307		fi, err := os.Stat(target)
308		require.NoError(t, err)
309		assert.Equal(t, os.FileMode(0o644), fi.Mode().Perm(), "target untouched")
310	})
311}
312
313func TestDirectoryChecks(t *testing.T) {
314	t.Run("loose mode", func(t *testing.T) {
315		rd := setup(t)
316		d := filepath.Join(rd, dirName)
317		require.NoError(t, os.Mkdir(d, 0o700))
318		require.NoError(t, os.Chmod(d, 0o755))
319		_, err := Listen()
320		assert.ErrorIs(t, err, ErrInsecure)
321		_, err = Stop(ctx(t))
322		assert.ErrorIs(t, err, ErrInsecure)
323		_, err = Query(ctx(t))
324		assert.ErrorIs(t, err, ErrInsecure)
325	})
326	t.Run("symlink", func(t *testing.T) {
327		rd := setup(t)
328		real := filepath.Join(rd, "elsewhere")
329		require.NoError(t, os.Mkdir(real, 0o700))
330		require.NoError(t, os.Symlink(real, filepath.Join(rd, dirName)))
331		_, err := Listen()
332		assert.ErrorIs(t, err, ErrInsecure)
333		_, err = Stop(ctx(t))
334		assert.ErrorIs(t, err, ErrInsecure)
335		entries, err := os.ReadDir(real)
336		require.NoError(t, err)
337		assert.Empty(t, entries)
338	})
339	t.Run("regular file", func(t *testing.T) {
340		rd := setup(t)
341		require.NoError(t, os.WriteFile(filepath.Join(rd, dirName), nil, 0o600))
342		_, err := Listen()
343		assert.ErrorIs(t, err, ErrInsecure)
344	})
345	t.Run("other owner", func(t *testing.T) {
346		rd := setup(t)
347		require.NoError(t, os.Mkdir(filepath.Join(rd, dirName), 0o700))
348		fd, err := openSecureDir(rd, false, uint32(os.Geteuid())+1)
349		if err == nil {
350			unix.Close(fd)
351		}
352		assert.ErrorIs(t, err, ErrInsecure)
353	})
354}
355
356func TestClientRejectsLooseSocket(t *testing.T) {
357	rd := setup(t)
358	listenT(t)
359	require.NoError(t, os.Chmod(sockPath(rd), 0o666))
360	_, err := Stop(ctx(t))
361	assert.ErrorIs(t, err, ErrInsecure)
362}
363
364func TestForeignPeerRejected(t *testing.T) {
365	setup(t)
366	s, err := listen(os.Getenv("XDG_RUNTIME_DIR"), uint32(os.Geteuid())+1)
367	require.NoError(t, err)
368	t.Cleanup(func() { s.Close() })
369
370	ok, err := Stop(ctx(t))
371	assert.Error(t, err)
372	assert.False(t, ok)
373	select {
374	case <-s.Requests():
375		t.Fatal("foreign peer delivered a request")
376	case <-time.After(100 * time.Millisecond):
377	}
378}
379
380func TestRequestLimitsAndProtocol(t *testing.T) {
381	rd := setup(t)
382	s := listenT(t)
383
384	expectClosedSilently := func(t *testing.T, payload string) {
385		t.Helper()
386		c := dial(t, rd)
387		_, _ = c.Write([]byte(payload))
388		c.SetReadDeadline(time.Now().Add(3 * time.Second))
389		b, err := io.ReadAll(c)
390		if err != nil {
391			// Unread request bytes make close report a reset; still no reply.
392			require.ErrorIs(t, err, unix.ECONNRESET)
393		}
394		assert.Empty(t, b)
395	}
396	t.Run("oversized without newline", func(t *testing.T) {
397		expectClosedSilently(t, strings.Repeat("x", 4096))
398	})
399	t.Run("oversized with newline late", func(t *testing.T) {
400		expectClosedSilently(t, strings.Repeat("x", maxRequest)+"stop\n")
401	})
402	t.Run("unknown", func(t *testing.T) { expectClosedSilently(t, "quit\n") })
403	t.Run("case sensitive", func(t *testing.T) { expectClosedSilently(t, "STOP\n") })
404	t.Run("crlf", func(t *testing.T) { expectClosedSilently(t, "stop\r\n") })
405	t.Run("stop in oversized junk", func(t *testing.T) {
406		expectClosedSilently(t, "status\nstop\n"+strings.Repeat("x", 100))
407	})
408
409	select {
410	case r := <-s.Requests():
411		t.Fatalf("invalid request delivered: %v", r)
412	default:
413	}
414
415	// Replies are exactly the two fixed lines.
416	c := dial(t, rd)
417	_, err := c.Write([]byte("status\n"))
418	require.NoError(t, err)
419	b, err := io.ReadAll(c)
420	require.NoError(t, err)
421	assert.Equal(t, "recording\n", string(b))
422}
423
424func TestDeadlineAndConnectionBound(t *testing.T) {
425	rd := setup(t)
426	listenT(t)
427
428	// Fill every slot with silent clients.
429	start := time.Now()
430	var idle []*net.UnixConn
431	for i := 0; i < connLimit; i++ {
432		idle = append(idle, dial(t, rd))
433	}
434	// While all slots are taken nothing is answered.
435	short, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
436	ok, err := Query(short)
437	cancel()
438	assert.Error(t, err)
439	assert.False(t, ok)
440
441	// The server drops the silent clients at its 1s deadline.
442	for _, c := range idle {
443		c.SetReadDeadline(time.Now().Add(3 * time.Second))
444		b, err := io.ReadAll(c)
445		require.NoError(t, err, "server closes timed-out clients")
446		assert.Empty(t, b)
447	}
448	assert.GreaterOrEqual(t, time.Since(start), ioTimeout-100*time.Millisecond)
449	assert.Less(t, time.Since(start), 3*time.Second)
450
451	// Slots are free again.
452	ok, err = Query(ctx(t))
453	require.NoError(t, err)
454	assert.True(t, ok)
455}
456
457func TestClientContext(t *testing.T) {
458	rd := setup(t)
459	listenT(t)
460	// Occupy every slot so the client's request stays unanswered.
461	for i := 0; i < connLimit; i++ {
462		dial(t, rd)
463	}
464	c, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
465	defer cancel()
466	start := time.Now()
467	ok, err := Query(c)
468	assert.Error(t, err)
469	assert.False(t, ok)
470	assert.Less(t, time.Since(start), 900*time.Millisecond)
471
472	cancelled, cancel2 := context.WithCancel(context.Background())
473	cancel2()
474	_, err = Stop(cancelled)
475	assert.Error(t, err)
476}
477
478func TestConcurrentStopAndStatus(t *testing.T) {
479	setup(t)
480	s := listenT(t)
481	base := runtime.NumGoroutine()
482
483	const n = 32
484	var wg sync.WaitGroup
485	errs := make(chan error, 2*n)
486	for i := 0; i < n; i++ {
487		wg.Add(2)
488		go func() {
489			defer wg.Done()
490			ok, err := Stop(ctx(t))
491			if err == nil && !ok {
492				err = errors.New("stop not acknowledged")
493			}
494			errs <- err
495		}()
496		go func() {
497			defer wg.Done()
498			ok, err := Query(ctx(t))
499			if err == nil && !ok {
500				err = errors.New("status false")
501			}
502			errs <- err
503		}()
504	}
505	wg.Wait()
506	close(errs)
507	for err := range errs {
508		assert.NoError(t, err)
509	}
510
511	// Stops coalesce into a single pending request.
512	require.Equal(t, RequestStop, <-s.Requests())
513	select {
514	case <-s.Requests():
515		t.Fatal("stops must coalesce")
516	default:
517	}
518
519	require.NoError(t, s.Close())
520	assert.Eventually(t, func() bool { return runtime.NumGoroutine() <= base }, 3*time.Second, 20*time.Millisecond,
521		"server goroutines must exit on Close")
522}
523
524func TestStopAckDoesNotBlockOnUnreadRequests(t *testing.T) {
525	setup(t)
526	listenT(t) // nobody reads Requests
527	for i := 0; i < 3; i++ {
528		ok, err := Stop(ctx(t))
529		require.NoError(t, err)
530		assert.True(t, ok)
531	}
532}
533
534// The owner may Close as soon as it sees the stop; the client must still get
535// its ack instead of EOF. Timing-dependent: the old order failed here only
536// under load, so this guards the race rather than proving the order.
537func TestStopAckSurvivesImmediateClose(t *testing.T) {
538	setup(t)
539	c := ctx(t)
540	for i := 0; i < 200; i++ {
541		s := listenT(t)
542		closed := make(chan struct{})
543		go func() {
544			defer close(closed)
545			select {
546			case <-s.Requests():
547				s.Close()
548			case <-c.Done():
549			}
550		}()
551		ok, err := Stop(c)
552		require.NoError(t, err, "attempt %d", i)
553		require.True(t, ok)
554		select {
555		case <-closed:
556		case <-time.After(5 * time.Second):
557			t.Fatal("close did not finish")
558		}
559	}
560}
561
562// Clients racing the owner's shutdown see either an ack or no recording,
563// never an error.
564func TestStopDuringShutdownIsNotAnError(t *testing.T) {
565	setup(t)
566	c := ctx(t)
567	for i := 0; i < 50; i++ {
568		s := listenT(t)
569		go func() {
570			select {
571			case <-s.Requests():
572				s.Close()
573			case <-c.Done():
574			}
575		}()
576		var wg sync.WaitGroup
577		errs := make(chan error, 8)
578		for j := 0; j < cap(errs); j++ {
579			wg.Add(1)
580			go func() {
581				defer wg.Done()
582				_, err := Stop(c)
583				errs <- err
584			}()
585		}
586		wg.Wait()
587		close(errs)
588		for err := range errs {
589			require.NoError(t, err, "attempt %d", i)
590		}
591		require.NoError(t, s.Close())
592	}
593}
594
595// A server that drops the client but keeps serving is an error, and a
596// caller cancelled while the client waits gets context.Canceled.
597func TestDroppedByLiveServer(t *testing.T) {
598	setup(t)
599	s, err := listen(os.Getenv("XDG_RUNTIME_DIR"), uint32(os.Geteuid())+1)
600	require.NoError(t, err)
601	t.Cleanup(func() { s.Close() })
602
603	_, err = Stop(ctx(t))
604	require.Error(t, err)
605	assert.Contains(t, err.Error(), "control: server closed the connection")
606
607	c, cancel := context.WithCancel(context.Background())
608	time.AfterFunc(100*time.Millisecond, cancel)
609	_, err = Stop(c)
610	assert.ErrorIs(t, err, context.Canceled)
611}
612
613func TestCloseLifecycle(t *testing.T) {
614	rd := setup(t)
615	s, err := Listen()
616	require.NoError(t, err)
617
618	idle := dial(t, rd) // an in-flight connection must not delay Close
619	done := make(chan struct{})
620	var wg sync.WaitGroup
621	for i := 0; i < 4; i++ {
622		wg.Add(1)
623		go func() { defer wg.Done(); assert.NoError(t, s.Close()) }()
624	}
625	go func() { wg.Wait(); close(done) }()
626	select {
627	case <-done:
628	case <-time.After(3 * time.Second):
629		t.Fatal("Close blocked")
630	}
631	assert.NoError(t, s.Close(), "idempotent")
632
633	idle.SetReadDeadline(time.Now().Add(time.Second))
634	_, err = io.ReadAll(idle)
635	if err != nil { // a not-yet-accepted connection is reset by the listener close
636		assert.ErrorIs(t, err, unix.ECONNRESET)
637	}
638
639	_, err = os.Lstat(sockPath(rd))
640	assert.True(t, os.IsNotExist(err))
641	fi, err := os.Lstat(filepath.Join(rd, dirName, lockName))
642	require.NoError(t, err, "lock file persists")
643	assert.Equal(t, int64(0), fi.Size(), "lock file stays empty")
644
645	ok, err := Stop(ctx(t))
646	assert.NoError(t, err)
647	assert.False(t, ok)
648}
649
650func TestCloseKeepsReplacedSocketPath(t *testing.T) {
651	rd := setup(t)
652	s, err := Listen()
653	require.NoError(t, err)
654	// Someone swaps our inode for something else; Close must not remove it.
655	require.NoError(t, os.Remove(sockPath(rd)))
656	require.NoError(t, os.WriteFile(sockPath(rd), []byte("other"), 0o600))
657	require.NoError(t, s.Close())
658	b, err := os.ReadFile(sockPath(rd))
659	require.NoError(t, err)
660	assert.Equal(t, "other", string(b))
661}
662
663func TestListenFailureLeavesNothingBehind(t *testing.T) {
664	rd := setup(t)
665	require.NoError(t, os.Mkdir(filepath.Join(rd, dirName), 0o700))
666	require.NoError(t, os.WriteFile(sockPath(rd), nil, 0o600))
667	_, err := Listen()
668	require.Error(t, err)
669	// The lock must have been released by the failed attempt.
670	require.NoError(t, os.Remove(sockPath(rd)))
671	s, err := Listen()
672	require.NoError(t, err)
673	require.NoError(t, s.Close())
674}
675
676func TestLongRuntimeDir(t *testing.T) {
677	long := filepath.Join(t.TempDir(), strings.Repeat("d", 120), strings.Repeat("e", 120))
678	require.NoError(t, os.MkdirAll(long, 0o700))
679	t.Setenv("XDG_RUNTIME_DIR", long)
680	s := listenT(t) // longer than sun_path, works through the directory fd
681	ok, err := Query(ctx(t))
682	require.NoError(t, err)
683	assert.True(t, ok)
684	require.NoError(t, s.Close())
685}

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 2

Tags 9

↑↓ moveenter openesc close