detached 27e6e25

fix(darwin): harden process inspection and pty tests

commit
27e6e25b8a574980f8ba5a599ef9ed67f33f7847
author
brice · · 2026-07-16 18:45 UTC
parent
c46ba52
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +131 -32
M internal/adapters/pty/pty_darwin_test.go +1 -14
@@ -57,20 +57,7 @@ func TestOpen_DarwinEchoAndOutputEOF(t *testing.T) {
5757 _, err = io.WriteString(echoPTY, "roundtrip\n")
5858 require.NoError(t, err)
5959
60- buf := make([]byte, 256)
61- deadline := time.Now().Add(5 * time.Second)
62- var output string
63- for !strings.Contains(output, "roundtrip") {
64- if time.Now().After(deadline) {
65- t.Fatalf("did not read echoed data, got %q", output)
66- }
67- n, readErr := echoPTY.Read(buf)
68- output += string(buf[:n])
69- if readErr != nil {
70- require.ErrorIs(t, readErr, io.EOF)
71- break
72- }
73- }
60+ output := readUntilDarwin(t, echoPTY, "roundtrip", 5*time.Second)
7461 require.Contains(t, output, "roundtrip")
7562 }
7663
@@ -57,20 +57,7 @@ func TestOpen_DarwinEchoAndOutputEOF(t *testing.T) {
57 _, err = io.WriteString(echoPTY, "roundtrip\n")57 _, err = io.WriteString(echoPTY, "roundtrip\n")
58 require.NoError(t, err)58 require.NoError(t, err)
59 59
60- buf := make([]byte, 256)60+ output := readUntilDarwin(t, echoPTY, "roundtrip", 5*time.Second)
61- deadline := time.Now().Add(5 * time.Second)
62- var output string
63- for !strings.Contains(output, "roundtrip") {
64- if time.Now().After(deadline) {
65- t.Fatalf("did not read echoed data, got %q", output)
66- }
67- n, readErr := echoPTY.Read(buf)
68- output += string(buf[:n])
69- if readErr != nil {
70- require.ErrorIs(t, readErr, io.EOF)
71- break
72- }
73- }
74 require.Contains(t, output, "roundtrip")61 require.Contains(t, output, "roundtrip")
75 }62 }
76 63
M internal/platform/process_darwin_arm64.go +27 -8
@@ -22,6 +22,8 @@ const (
2222 procPIDVnodePathInfo = 9
2323 darwinKinfoProcSize = 0x288
2424 darwinVnodePathInfoLen = 2352
25+ maxDarwinPID = 1<<31 - 1
26+ rawSysctlMaxRetries = 3
2527 )
2628
2729 // ProcessInspector implements ports.ProcessInspector using Darwin sysctl and
@@ -100,10 +102,11 @@ func ProcessCwd(pid int) (string, error) {
100102
101103 // ProcessComm returns the p_comm field from KERN_PROC_PID data.
102104 func ProcessComm(pid int) (string, error) {
103- if pid <= 0 {
105+ pid32, ok := darwinPID(pid)
106+ if !ok {
104107 return "", fmt.Errorf("process comm: invalid pid %d", pid)
105108 }
106- data, err := rawSysctl([]int32{ctlKern, kernProc, kernProcPID, int32(pid)})
109+ data, err := rawSysctl([]int32{ctlKern, kernProc, kernProcPID, pid32})
107110 if err != nil {
108111 return "", fmt.Errorf("process comm: sysctl pid %d: %w", pid, err)
109112 }
@@ -124,10 +127,11 @@ func ProcessComm(pid int) (string, error) {
124127
125128 // ProcessArgv returns argv from KERN_PROCARGS2.
126129 func ProcessArgv(pid int) ([]string, error) {
127- if pid <= 0 {
130+ pid32, ok := darwinPID(pid)
131+ if !ok {
128132 return nil, fmt.Errorf("process argv: invalid pid %d", pid)
129133 }
130- data, err := rawSysctl([]int32{ctlKern, kernProcArgs2, int32(pid)})
134+ data, err := rawSysctl([]int32{ctlKern, kernProcArgs2, pid32})
131135 if err != nil {
132136 return nil, fmt.Errorf("process argv: sysctl pid %d: %w", pid, err)
133137 }
@@ -162,12 +166,23 @@ func readDarwinProcessRecords() ([]processRecord, error) {
162166 return recs, nil
163167 }
164168
169+func darwinPID(pid int) (int32, bool) {
170+ if pid <= 0 || pid > maxDarwinPID {
171+ return 0, false
172+ }
173+ return int32(pid), true
174+}
175+
165176 // rawSysctl reads a binary sysctl MIB, retrying if a growing result races the
166177 // size query. It is the stdlib syscall equivalent of sysctl(3).
167178 func rawSysctl(mib []int32) ([]byte, error) {
168- for {
179+ return rawSysctlWith(mib, sysctl)
180+}
181+
182+func rawSysctlWith(mib []int32, call func([]int32, *byte, *uintptr) error) ([]byte, error) {
183+ for retry := 0; retry <= rawSysctlMaxRetries; retry++ {
169184 var size uintptr
170- if err := sysctl(mib, nil, &size); err != nil {
185+ if err := call(mib, nil, &size); err != nil {
171186 return nil, err
172187 }
173188 if size == 0 {
@@ -175,14 +190,18 @@ func rawSysctl(mib []int32) ([]byte, error) {
175190 }
176191 buf := make([]byte, size)
177192 n := size
178- if err := sysctl(mib, &buf[0], &n); err != nil {
179- if err == syscall.ENOMEM {
193+ if err := call(mib, &buf[0], &n); err != nil {
194+ if err == syscall.ENOMEM && retry < rawSysctlMaxRetries {
180195 continue
181196 }
197+ if err == syscall.ENOMEM {
198+ return nil, fmt.Errorf("sysctl %v: result grew after %d retries: %w", mib, rawSysctlMaxRetries, err)
199+ }
182200 return nil, err
183201 }
184202 return buf[:n], nil
185203 }
204+ panic("unreachable")
186205 }
187206
188207 func sysctl(mib []int32, old *byte, oldlen *uintptr) error {
@@ -22,6 +22,8 @@ const (
22 procPIDVnodePathInfo = 922 procPIDVnodePathInfo = 9
23 darwinKinfoProcSize = 0x28823 darwinKinfoProcSize = 0x288
24 darwinVnodePathInfoLen = 235224 darwinVnodePathInfoLen = 2352
25+ maxDarwinPID = 1<<31 - 1
26+ rawSysctlMaxRetries = 3
25 )27 )
26 28
27 // ProcessInspector implements ports.ProcessInspector using Darwin sysctl and29 // ProcessInspector implements ports.ProcessInspector using Darwin sysctl and
@@ -100,10 +102,11 @@ func ProcessCwd(pid int) (string, error) {
100 102
101 // ProcessComm returns the p_comm field from KERN_PROC_PID data.103 // ProcessComm returns the p_comm field from KERN_PROC_PID data.
102 func ProcessComm(pid int) (string, error) {104 func ProcessComm(pid int) (string, error) {
103- if pid <= 0 {105+ pid32, ok := darwinPID(pid)
106+ if !ok {
104 return "", fmt.Errorf("process comm: invalid pid %d", pid)107 return "", fmt.Errorf("process comm: invalid pid %d", pid)
105 }108 }
106- data, err := rawSysctl([]int32{ctlKern, kernProc, kernProcPID, int32(pid)})109+ data, err := rawSysctl([]int32{ctlKern, kernProc, kernProcPID, pid32})
107 if err != nil {110 if err != nil {
108 return "", fmt.Errorf("process comm: sysctl pid %d: %w", pid, err)111 return "", fmt.Errorf("process comm: sysctl pid %d: %w", pid, err)
109 }112 }
@@ -124,10 +127,11 @@ func ProcessComm(pid int) (string, error) {
124 127
125 // ProcessArgv returns argv from KERN_PROCARGS2.128 // ProcessArgv returns argv from KERN_PROCARGS2.
126 func ProcessArgv(pid int) ([]string, error) {129 func ProcessArgv(pid int) ([]string, error) {
127- if pid <= 0 {130+ pid32, ok := darwinPID(pid)
131+ if !ok {
128 return nil, fmt.Errorf("process argv: invalid pid %d", pid)132 return nil, fmt.Errorf("process argv: invalid pid %d", pid)
129 }133 }
130- data, err := rawSysctl([]int32{ctlKern, kernProcArgs2, int32(pid)})134+ data, err := rawSysctl([]int32{ctlKern, kernProcArgs2, pid32})
131 if err != nil {135 if err != nil {
132 return nil, fmt.Errorf("process argv: sysctl pid %d: %w", pid, err)136 return nil, fmt.Errorf("process argv: sysctl pid %d: %w", pid, err)
133 }137 }
@@ -162,12 +166,23 @@ func readDarwinProcessRecords() ([]processRecord, error) {
162 return recs, nil166 return recs, nil
163 }167 }
164 168
169+func darwinPID(pid int) (int32, bool) {
170+ if pid <= 0 || pid > maxDarwinPID {
171+ return 0, false
172+ }
173+ return int32(pid), true
174+}
175+
165 // rawSysctl reads a binary sysctl MIB, retrying if a growing result races the176 // rawSysctl reads a binary sysctl MIB, retrying if a growing result races the
166 // size query. It is the stdlib syscall equivalent of sysctl(3).177 // size query. It is the stdlib syscall equivalent of sysctl(3).
167 func rawSysctl(mib []int32) ([]byte, error) {178 func rawSysctl(mib []int32) ([]byte, error) {
168- for {179+ return rawSysctlWith(mib, sysctl)
180+}
181+
182+func rawSysctlWith(mib []int32, call func([]int32, *byte, *uintptr) error) ([]byte, error) {
183+ for retry := 0; retry <= rawSysctlMaxRetries; retry++ {
169 var size uintptr184 var size uintptr
170- if err := sysctl(mib, nil, &size); err != nil {185+ if err := call(mib, nil, &size); err != nil {
171 return nil, err186 return nil, err
172 }187 }
173 if size == 0 {188 if size == 0 {
@@ -175,14 +190,18 @@ func rawSysctl(mib []int32) ([]byte, error) {
175 }190 }
176 buf := make([]byte, size)191 buf := make([]byte, size)
177 n := size192 n := size
178- if err := sysctl(mib, &buf[0], &n); err != nil {193+ if err := call(mib, &buf[0], &n); err != nil {
179- if err == syscall.ENOMEM {194+ if err == syscall.ENOMEM && retry < rawSysctlMaxRetries {
180 continue195 continue
181 }196 }
197+ if err == syscall.ENOMEM {
198+ return nil, fmt.Errorf("sysctl %v: result grew after %d retries: %w", mib, rawSysctlMaxRetries, err)
199+ }
182 return nil, err200 return nil, err
183 }201 }
184 return buf[:n], nil202 return buf[:n], nil
185 }203 }
204+ panic("unreachable")
186 }205 }
187 206
188 func sysctl(mib []int32, old *byte, oldlen *uintptr) error {207 func sysctl(mib []int32, old *byte, oldlen *uintptr) error {
M internal/platform/process_darwin_arm64_test.go +90 -10
@@ -3,7 +3,11 @@
33 package platform
44
55 import (
6+ "errors"
67 "os"
8+ "os/exec"
9+ "path/filepath"
10+ "strings"
711 "syscall"
812 "testing"
913 "unsafe"
@@ -37,31 +41,107 @@ func TestDarwinProcessInspectorCurrentProcess(t *testing.T) {
3741 if err != nil {
3842 t.Fatalf("Cwd(%d): %v", pid, err)
3943 }
40- if cwd == "" {
41- t.Fatal("Cwd returned an empty path")
44+ cwdInfo, err := os.Stat(cwd)
45+ if err != nil {
46+ t.Fatalf("stat Cwd(%d) result %q: %v", pid, cwd, err)
47+ }
48+ currentDirInfo, err := os.Stat(".")
49+ if err != nil {
50+ t.Fatal(err)
51+ }
52+ if !os.SameFile(cwdInfo, currentDirInfo) {
53+ t.Fatalf("Cwd(%d) = %q; does not identify the current directory", pid, cwd)
4254 }
4355
4456 comm, err := inspector.Comm(pid)
4557 if err != nil {
4658 t.Fatalf("Comm(%d): %v", pid, err)
4759 }
48- if comm == "" {
49- t.Fatal("Comm returned an empty command")
60+ wantComm := filepath.Base(os.Args[0])
61+ if len(wantComm) > len(externProc{}.Comm)-1 {
62+ wantComm = wantComm[:len(externProc{}.Comm)-1]
63+ }
64+ if comm != wantComm {
65+ t.Fatalf("Comm(%d) = %q; want %q", pid, comm, wantComm)
5066 }
5167
5268 argv, err := inspector.Argv(pid)
5369 if err != nil {
5470 t.Fatalf("Argv(%d): %v", pid, err)
5571 }
56- if len(argv) == 0 {
57- t.Fatal("Argv returned no arguments")
72+ if len(argv) == 0 || argv[0] != os.Args[0] {
73+ t.Fatalf("Argv(%d) = %#v; want argv[0] %q", pid, argv, os.Args[0])
74+ }
75+
76+ cmd := exec.Command("/bin/sleep", "5")
77+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
78+ if err := cmd.Start(); err != nil {
79+ t.Fatal(err)
80+ }
81+ defer func() {
82+ _ = cmd.Process.Kill()
83+ _ = cmd.Wait()
84+ }()
85+ groupArgv, err := inspector.GroupArgv(cmd.Process.Pid, -1)
86+ if err != nil {
87+ t.Fatalf("GroupArgv(child group): %v", err)
88+ }
89+ if len(groupArgv) != 2 || groupArgv[0] != "/bin/sleep" || groupArgv[1] != "5" {
90+ t.Fatalf("GroupArgv(child group) = %#v; want [/bin/sleep 5]", groupArgv)
5891 }
92+}
93+
94+func TestDarwinPIDRejectsUnrepresentableValues(t *testing.T) {
95+ for _, pid := range []int{0, -1, maxDarwinPID + 1} {
96+ if _, err := ProcessComm(pid); err == nil {
97+ t.Errorf("ProcessComm(%d) succeeded; want invalid PID error", pid)
98+ }
99+ if _, err := ProcessArgv(pid); err == nil {
100+ t.Errorf("ProcessArgv(%d) succeeded; want invalid PID error", pid)
101+ }
102+ }
103+}
59104
60- groupArgv, err := inspector.GroupArgv(syscall.Getpgrp(), -1)
105+func TestRawSysctlRetriesAfterENOMEM(t *testing.T) {
106+ var sizeCalls, readCalls int
107+ data, err := rawSysctlWith([]int32{1, 2}, func(_ []int32, old *byte, oldlen *uintptr) error {
108+ if old == nil {
109+ sizeCalls++
110+ *oldlen = 1
111+ return nil
112+ }
113+ readCalls++
114+ if readCalls == 1 {
115+ return syscall.ENOMEM
116+ }
117+ *old = 'x'
118+ *oldlen = 1
119+ return nil
120+ })
61121 if err != nil {
62- t.Fatalf("GroupArgv(current group): %v", err)
122+ t.Fatal(err)
123+ }
124+ if string(data) != "x" || sizeCalls != 2 || readCalls != 2 {
125+ t.Fatalf("rawSysctl calls = size %d, read %d, data %q; want 2, 2, x", sizeCalls, readCalls, data)
126+ }
127+}
128+
129+func TestRawSysctlStopsRetryingAfterLimit(t *testing.T) {
130+ var sizeCalls, readCalls int
131+ _, err := rawSysctlWith([]int32{1, 2}, func(_ []int32, old *byte, oldlen *uintptr) error {
132+ if old == nil {
133+ sizeCalls++
134+ *oldlen = 1
135+ return nil
136+ }
137+ readCalls++
138+ return syscall.ENOMEM
139+ })
140+ if !errors.Is(err, syscall.ENOMEM) || !strings.Contains(err.Error(), "result grew after") {
141+ t.Fatalf("rawSysctl exhaustion error = %v; want contextual ENOMEM", err)
63142 }
64- if len(groupArgv) == 0 {
65- t.Fatal("GroupArgv returned no arguments for the current process group")
143+ wantCalls := rawSysctlMaxRetries + 1
144+ if sizeCalls != wantCalls || readCalls != wantCalls {
145+ t.Fatalf("rawSysctl calls = size %d, read %d; want %d each", sizeCalls, readCalls, wantCalls)
66146 }
67147 }
@@ -3,7 +3,11 @@
3 package platform3 package platform
4 4
5 import (5 import (
6+ "errors"
6 "os"7 "os"
8+ "os/exec"
9+ "path/filepath"
10+ "strings"
7 "syscall"11 "syscall"
8 "testing"12 "testing"
9 "unsafe"13 "unsafe"
@@ -37,31 +41,107 @@ func TestDarwinProcessInspectorCurrentProcess(t *testing.T) {
37 if err != nil {41 if err != nil {
38 t.Fatalf("Cwd(%d): %v", pid, err)42 t.Fatalf("Cwd(%d): %v", pid, err)
39 }43 }
40- if cwd == "" {44+ cwdInfo, err := os.Stat(cwd)
41- t.Fatal("Cwd returned an empty path")45+ if err != nil {
46+ t.Fatalf("stat Cwd(%d) result %q: %v", pid, cwd, err)
47+ }
48+ currentDirInfo, err := os.Stat(".")
49+ if err != nil {
50+ t.Fatal(err)
51+ }
52+ if !os.SameFile(cwdInfo, currentDirInfo) {
53+ t.Fatalf("Cwd(%d) = %q; does not identify the current directory", pid, cwd)
42 }54 }
43 55
44 comm, err := inspector.Comm(pid)56 comm, err := inspector.Comm(pid)
45 if err != nil {57 if err != nil {
46 t.Fatalf("Comm(%d): %v", pid, err)58 t.Fatalf("Comm(%d): %v", pid, err)
47 }59 }
48- if comm == "" {60+ wantComm := filepath.Base(os.Args[0])
49- t.Fatal("Comm returned an empty command")61+ if len(wantComm) > len(externProc{}.Comm)-1 {
62+ wantComm = wantComm[:len(externProc{}.Comm)-1]
63+ }
64+ if comm != wantComm {
65+ t.Fatalf("Comm(%d) = %q; want %q", pid, comm, wantComm)
50 }66 }
51 67
52 argv, err := inspector.Argv(pid)68 argv, err := inspector.Argv(pid)
53 if err != nil {69 if err != nil {
54 t.Fatalf("Argv(%d): %v", pid, err)70 t.Fatalf("Argv(%d): %v", pid, err)
55 }71 }
56- if len(argv) == 0 {72+ if len(argv) == 0 || argv[0] != os.Args[0] {
57- t.Fatal("Argv returned no arguments")73+ t.Fatalf("Argv(%d) = %#v; want argv[0] %q", pid, argv, os.Args[0])
74+ }
75+
76+ cmd := exec.Command("/bin/sleep", "5")
77+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
78+ if err := cmd.Start(); err != nil {
79+ t.Fatal(err)
80+ }
81+ defer func() {
82+ _ = cmd.Process.Kill()
83+ _ = cmd.Wait()
84+ }()
85+ groupArgv, err := inspector.GroupArgv(cmd.Process.Pid, -1)
86+ if err != nil {
87+ t.Fatalf("GroupArgv(child group): %v", err)
88+ }
89+ if len(groupArgv) != 2 || groupArgv[0] != "/bin/sleep" || groupArgv[1] != "5" {
90+ t.Fatalf("GroupArgv(child group) = %#v; want [/bin/sleep 5]", groupArgv)
58 }91 }
92+}
93+
94+func TestDarwinPIDRejectsUnrepresentableValues(t *testing.T) {
95+ for _, pid := range []int{0, -1, maxDarwinPID + 1} {
96+ if _, err := ProcessComm(pid); err == nil {
97+ t.Errorf("ProcessComm(%d) succeeded; want invalid PID error", pid)
98+ }
99+ if _, err := ProcessArgv(pid); err == nil {
100+ t.Errorf("ProcessArgv(%d) succeeded; want invalid PID error", pid)
101+ }
102+ }
103+}
59 104
60- groupArgv, err := inspector.GroupArgv(syscall.Getpgrp(), -1)105+func TestRawSysctlRetriesAfterENOMEM(t *testing.T) {
106+ var sizeCalls, readCalls int
107+ data, err := rawSysctlWith([]int32{1, 2}, func(_ []int32, old *byte, oldlen *uintptr) error {
108+ if old == nil {
109+ sizeCalls++
110+ *oldlen = 1
111+ return nil
112+ }
113+ readCalls++
114+ if readCalls == 1 {
115+ return syscall.ENOMEM
116+ }
117+ *old = 'x'
118+ *oldlen = 1
119+ return nil
120+ })
61 if err != nil {121 if err != nil {
62- t.Fatalf("GroupArgv(current group): %v", err)122+ t.Fatal(err)
123+ }
124+ if string(data) != "x" || sizeCalls != 2 || readCalls != 2 {
125+ t.Fatalf("rawSysctl calls = size %d, read %d, data %q; want 2, 2, x", sizeCalls, readCalls, data)
126+ }
127+}
128+
129+func TestRawSysctlStopsRetryingAfterLimit(t *testing.T) {
130+ var sizeCalls, readCalls int
131+ _, err := rawSysctlWith([]int32{1, 2}, func(_ []int32, old *byte, oldlen *uintptr) error {
132+ if old == nil {
133+ sizeCalls++
134+ *oldlen = 1
135+ return nil
136+ }
137+ readCalls++
138+ return syscall.ENOMEM
139+ })
140+ if !errors.Is(err, syscall.ENOMEM) || !strings.Contains(err.Error(), "result grew after") {
141+ t.Fatalf("rawSysctl exhaustion error = %v; want contextual ENOMEM", err)
63 }142 }
64- if len(groupArgv) == 0 {143+ wantCalls := rawSysctlMaxRetries + 1
65- t.Fatal("GroupArgv returned no arguments for the current process group")144+ if sizeCalls != wantCalls || readCalls != wantCalls {
145+ t.Fatalf("rawSysctl calls = size %d, read %d; want %d each", sizeCalls, readCalls, wantCalls)
66 }146 }
67 }147 }
M pkg/rawterm/rawterm_test.go +13 -0
@@ -90,6 +90,11 @@ func TestMakeRawRestore(t *testing.T) {
9090
9191 fd := int(slave.Fd())
9292
93+ var original syscall.Termios
94+ if err := ioctl(fd, reqGetTermios, unsafe.Pointer(&original)); err != nil {
95+ t.Fatalf("get termios before MakeRaw: %v", err)
96+ }
97+
9398 old, err := MakeRaw(fd)
9499 if err != nil {
95100 t.Fatalf("MakeRaw: %v", err)
@@ -115,6 +120,14 @@ func TestMakeRawRestore(t *testing.T) {
115120 if err := Restore(fd, old); err != nil {
116121 t.Fatalf("Restore: %v", err)
117122 }
123+
124+ var restored syscall.Termios
125+ if err := ioctl(fd, reqGetTermios, unsafe.Pointer(&restored)); err != nil {
126+ t.Fatalf("get termios after Restore: %v", err)
127+ }
128+ if restored != original {
129+ t.Errorf("termios after Restore = %#v, want original %#v", restored, original)
130+ }
118131 }
119132
120133 func TestForegroundProcessGroup(t *testing.T) {
@@ -90,6 +90,11 @@ func TestMakeRawRestore(t *testing.T) {
90 90
91 fd := int(slave.Fd())91 fd := int(slave.Fd())
92 92
93+ var original syscall.Termios
94+ if err := ioctl(fd, reqGetTermios, unsafe.Pointer(&original)); err != nil {
95+ t.Fatalf("get termios before MakeRaw: %v", err)
96+ }
97+
93 old, err := MakeRaw(fd)98 old, err := MakeRaw(fd)
94 if err != nil {99 if err != nil {
95 t.Fatalf("MakeRaw: %v", err)100 t.Fatalf("MakeRaw: %v", err)
@@ -115,6 +120,14 @@ func TestMakeRawRestore(t *testing.T) {
115 if err := Restore(fd, old); err != nil {120 if err := Restore(fd, old); err != nil {
116 t.Fatalf("Restore: %v", err)121 t.Fatalf("Restore: %v", err)
117 }122 }
123+
124+ var restored syscall.Termios
125+ if err := ioctl(fd, reqGetTermios, unsafe.Pointer(&restored)); err != nil {
126+ t.Fatalf("get termios after Restore: %v", err)
127+ }
128+ if restored != original {
129+ t.Errorf("termios after Restore = %#v, want original %#v", restored, original)
130+ }
118 }131 }
119 132
120 func TestForegroundProcessGroup(t *testing.T) {133 func TestForegroundProcessGroup(t *testing.T) {

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