detached 862d933

fix(startup): guard collector output cleanup

commit
862d933754ec958204ee2da116d65fa6956c534a
author
brice · · 2026-07-14 09:00 UTC
committer
brice · · 2026-07-14 09:35 UTC
parent
e4b5a97
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +136 -6
M docs/first-presentation.md +4 -1
@@ -25,10 +25,13 @@ Build the candidate, then run:
2525
2626 ```bash
2727 DUMBER_FIRST_PRESENTATION_BIN="$PWD/dist/dumber" \
28+DUMBER_FIRST_PRESENTATION_OUTPUT="$PWD/phase1/first-presentation-$(date +%s)" \
2829 scripts/collect_first_presentation.sh
2930 ```
3031
31-The script requires the current display and the CEF 147 runtime at
32+The output directory must be a new, absolute directory below an existing
33+non-symlink parent. The collector refuses to clear or reuse a caller-supplied
34+path; review and publish a fresh collection explicitly. The script requires the current display and the CEF 147 runtime at
3235 `~/.local/share/cef-147-runtime` (override with `DUMBER_CEF_DIR`). It performs
3336 exactly five bounded launches with fresh XDG homes and CEF root cache, fixes the
3437 DMABUF/Vulkan environment, and writes `metadata.json`, `run-01.json` through
@@ -25,10 +25,13 @@ Build the candidate, then run:
25 25
26 ```bash26 ```bash
27 DUMBER_FIRST_PRESENTATION_BIN="$PWD/dist/dumber" \27 DUMBER_FIRST_PRESENTATION_BIN="$PWD/dist/dumber" \
28+DUMBER_FIRST_PRESENTATION_OUTPUT="$PWD/phase1/first-presentation-$(date +%s)" \
28 scripts/collect_first_presentation.sh29 scripts/collect_first_presentation.sh
29 ```30 ```
30 31
31-The script requires the current display and the CEF 147 runtime at32+The output directory must be a new, absolute directory below an existing
33+non-symlink parent. The collector refuses to clear or reuse a caller-supplied
34+path; review and publish a fresh collection explicitly. The script requires the current display and the CEF 147 runtime at
32 `~/.local/share/cef-147-runtime` (override with `DUMBER_CEF_DIR`). It performs35 `~/.local/share/cef-147-runtime` (override with `DUMBER_CEF_DIR`). It performs
33 exactly five bounded launches with fresh XDG homes and CEF root cache, fixes the36 exactly five bounded launches with fresh XDG homes and CEF root cache, fixes the
34 DMABUF/Vulkan environment, and writes `metadata.json`, `run-01.json` through37 DMABUF/Vulkan environment, and writes `metadata.json`, `run-01.json` through
M internal/logging/first_presentation_collector_test.go +86 -0
@@ -20,6 +20,92 @@ const validFirstPresentationLog = `{"message":"startup_trace: milestone","milest
2020 {"message":"startup_trace: milestone","milestone":"first_gtk_presentation","t_ms":7,"delta_ms":1}
2121 {"message":"startup_trace: first presentation","backend":"gdk-dmabuf","incomplete_reason":"","total_ms":7,"host":"alice"}`
2222
23+func TestFirstPresentationCollectorNeverRecursivelyDeletesCallerOutput(t *testing.T) {
24+ repoRoot, err := filepath.Abs(filepath.Join("..", ".."))
25+ require.NoError(t, err)
26+ script, err := os.ReadFile(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
27+ require.NoError(t, err)
28+ require.NotContains(t, string(script), "rm -rf \"$output\"")
29+ require.NotContains(t, string(script), "rm -rf -- \"$output\"")
30+}
31+
32+func TestFirstPresentationCollectorRejectsUnsafeOutputPaths(t *testing.T) {
33+ repoRoot, err := filepath.Abs(filepath.Join("..", ".."))
34+ require.NoError(t, err)
35+ temp := t.TempDir()
36+ runtime := filepath.Join(temp, "cef-147-runtime")
37+ require.NoError(t, os.Mkdir(runtime, 0o755))
38+ binary := filepath.Join(temp, "dumber")
39+ require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
40+ home := filepath.Join(temp, "home")
41+ require.NoError(t, os.Mkdir(home, 0o755))
42+
43+ unrelated := filepath.Join(temp, "unrelated")
44+ require.NoError(t, os.Mkdir(unrelated, 0o755))
45+ unrelatedSentinel := filepath.Join(unrelated, "keep")
46+ require.NoError(t, os.WriteFile(unrelatedSentinel, []byte("do not delete"), 0o600))
47+
48+ safeParent := filepath.Join(temp, "safe")
49+ require.NoError(t, os.Mkdir(safeParent, 0o755))
50+ parentSentinel := filepath.Join(safeParent, "keep")
51+ require.NoError(t, os.WriteFile(parentSentinel, []byte("do not delete"), 0o600))
52+
53+ external := filepath.Join(temp, "external")
54+ require.NoError(t, os.Mkdir(external, 0o755))
55+ externalSentinel := filepath.Join(external, "keep")
56+ require.NoError(t, os.WriteFile(externalSentinel, []byte("do not delete"), 0o600))
57+ link := filepath.Join(temp, "link")
58+ require.NoError(t, os.Symlink(external, link))
59+
60+ for _, test := range []struct {
61+ name string
62+ output string
63+ verify func(t *testing.T)
64+ }{
65+ {name: "empty", output: ""},
66+ {name: "root", output: "/"},
67+ {name: "home", output: home},
68+ {name: "existing unrelated directory", output: unrelated, verify: func(t *testing.T) {
69+ requireFileContents(t, unrelatedSentinel, "do not delete")
70+ }},
71+ {name: "parent traversal", output: safeParent + "/../escaped", verify: func(t *testing.T) {
72+ requireFileContents(t, parentSentinel, "do not delete")
73+ require.NoFileExists(t, filepath.Join(temp, "escaped"))
74+ }},
75+ {name: "symlink escape", output: filepath.Join(link, "escaped"), verify: func(t *testing.T) {
76+ requireFileContents(t, externalSentinel, "do not delete")
77+ require.NoFileExists(t, filepath.Join(external, "escaped"))
78+ }},
79+ } {
80+ t.Run(test.name, func(t *testing.T) {
81+ cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
82+ // An isolated directory prevents a regression from reaching the tracked
83+ // default artifact path while this test proves output validation is first.
84+ cmd.Dir = temp
85+ cmd.Env = append(os.Environ(),
86+ "HOME="+home,
87+ "DISPLAY=:test",
88+ "DUMBER_CEF_DIR="+runtime,
89+ "DUMBER_FIRST_PRESENTATION_BIN="+binary,
90+ "DUMBER_FIRST_PRESENTATION_OUTPUT="+test.output,
91+ )
92+ result, err := cmd.CombinedOutput()
93+ require.Errorf(t, err, "collector accepted unsafe output %q: %s", test.output, result)
94+ require.Contains(t, string(result), "unsafe output path")
95+ if test.verify != nil {
96+ test.verify(t)
97+ }
98+ })
99+ }
100+}
101+
102+func requireFileContents(t *testing.T, path, want string) {
103+ t.Helper()
104+ contents, err := os.ReadFile(path)
105+ require.NoError(t, err)
106+ require.Equal(t, want, string(contents))
107+}
108+
23109 func TestFirstPresentationCollectorSanitizesMachineLocalValues(t *testing.T) {
24110 repoRoot, err := filepath.Abs(filepath.Join("..", ".."))
25111 require.NoError(t, err)
@@ -20,6 +20,92 @@ const validFirstPresentationLog = `{"message":"startup_trace: milestone","milest
20 {"message":"startup_trace: milestone","milestone":"first_gtk_presentation","t_ms":7,"delta_ms":1}20 {"message":"startup_trace: milestone","milestone":"first_gtk_presentation","t_ms":7,"delta_ms":1}
21 {"message":"startup_trace: first presentation","backend":"gdk-dmabuf","incomplete_reason":"","total_ms":7,"host":"alice"}`21 {"message":"startup_trace: first presentation","backend":"gdk-dmabuf","incomplete_reason":"","total_ms":7,"host":"alice"}`
22 22
23+func TestFirstPresentationCollectorNeverRecursivelyDeletesCallerOutput(t *testing.T) {
24+ repoRoot, err := filepath.Abs(filepath.Join("..", ".."))
25+ require.NoError(t, err)
26+ script, err := os.ReadFile(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
27+ require.NoError(t, err)
28+ require.NotContains(t, string(script), "rm -rf \"$output\"")
29+ require.NotContains(t, string(script), "rm -rf -- \"$output\"")
30+}
31+
32+func TestFirstPresentationCollectorRejectsUnsafeOutputPaths(t *testing.T) {
33+ repoRoot, err := filepath.Abs(filepath.Join("..", ".."))
34+ require.NoError(t, err)
35+ temp := t.TempDir()
36+ runtime := filepath.Join(temp, "cef-147-runtime")
37+ require.NoError(t, os.Mkdir(runtime, 0o755))
38+ binary := filepath.Join(temp, "dumber")
39+ require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
40+ home := filepath.Join(temp, "home")
41+ require.NoError(t, os.Mkdir(home, 0o755))
42+
43+ unrelated := filepath.Join(temp, "unrelated")
44+ require.NoError(t, os.Mkdir(unrelated, 0o755))
45+ unrelatedSentinel := filepath.Join(unrelated, "keep")
46+ require.NoError(t, os.WriteFile(unrelatedSentinel, []byte("do not delete"), 0o600))
47+
48+ safeParent := filepath.Join(temp, "safe")
49+ require.NoError(t, os.Mkdir(safeParent, 0o755))
50+ parentSentinel := filepath.Join(safeParent, "keep")
51+ require.NoError(t, os.WriteFile(parentSentinel, []byte("do not delete"), 0o600))
52+
53+ external := filepath.Join(temp, "external")
54+ require.NoError(t, os.Mkdir(external, 0o755))
55+ externalSentinel := filepath.Join(external, "keep")
56+ require.NoError(t, os.WriteFile(externalSentinel, []byte("do not delete"), 0o600))
57+ link := filepath.Join(temp, "link")
58+ require.NoError(t, os.Symlink(external, link))
59+
60+ for _, test := range []struct {
61+ name string
62+ output string
63+ verify func(t *testing.T)
64+ }{
65+ {name: "empty", output: ""},
66+ {name: "root", output: "/"},
67+ {name: "home", output: home},
68+ {name: "existing unrelated directory", output: unrelated, verify: func(t *testing.T) {
69+ requireFileContents(t, unrelatedSentinel, "do not delete")
70+ }},
71+ {name: "parent traversal", output: safeParent + "/../escaped", verify: func(t *testing.T) {
72+ requireFileContents(t, parentSentinel, "do not delete")
73+ require.NoFileExists(t, filepath.Join(temp, "escaped"))
74+ }},
75+ {name: "symlink escape", output: filepath.Join(link, "escaped"), verify: func(t *testing.T) {
76+ requireFileContents(t, externalSentinel, "do not delete")
77+ require.NoFileExists(t, filepath.Join(external, "escaped"))
78+ }},
79+ } {
80+ t.Run(test.name, func(t *testing.T) {
81+ cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
82+ // An isolated directory prevents a regression from reaching the tracked
83+ // default artifact path while this test proves output validation is first.
84+ cmd.Dir = temp
85+ cmd.Env = append(os.Environ(),
86+ "HOME="+home,
87+ "DISPLAY=:test",
88+ "DUMBER_CEF_DIR="+runtime,
89+ "DUMBER_FIRST_PRESENTATION_BIN="+binary,
90+ "DUMBER_FIRST_PRESENTATION_OUTPUT="+test.output,
91+ )
92+ result, err := cmd.CombinedOutput()
93+ require.Errorf(t, err, "collector accepted unsafe output %q: %s", test.output, result)
94+ require.Contains(t, string(result), "unsafe output path")
95+ if test.verify != nil {
96+ test.verify(t)
97+ }
98+ })
99+ }
100+}
101+
102+func requireFileContents(t *testing.T, path, want string) {
103+ t.Helper()
104+ contents, err := os.ReadFile(path)
105+ require.NoError(t, err)
106+ require.Equal(t, want, string(contents))
107+}
108+
23 func TestFirstPresentationCollectorSanitizesMachineLocalValues(t *testing.T) {109 func TestFirstPresentationCollectorSanitizesMachineLocalValues(t *testing.T) {
24 repoRoot, err := filepath.Abs(filepath.Join("..", ".."))110 repoRoot, err := filepath.Abs(filepath.Join("..", ".."))
25 require.NoError(t, err)111 require.NoError(t, err)
M scripts/collect_first_presentation.sh +46 -5
@@ -8,11 +8,50 @@ readonly runs=5
88 readonly timeout_seconds="${DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS:-45}"
99 readonly runtime="${DUMBER_CEF_DIR:-$HOME/.local/share/cef-147-runtime}"
1010 readonly binary="${DUMBER_FIRST_PRESENTATION_BIN:-$PWD/dist/dumber}"
11-readonly output="${DUMBER_FIRST_PRESENTATION_OUTPUT:-$PWD/phase1/first-presentation}"
11+if [[ -v DUMBER_FIRST_PRESENTATION_OUTPUT ]]; then
12+ output="$DUMBER_FIRST_PRESENTATION_OUTPUT"
13+else
14+ output="$PWD/phase1/first-presentation"
15+fi
16+readonly output
1217 readonly upstream_module="github.com/bnema/purego-cef2gtk"
1318 readonly upstream_tag="v0.8.4"
1419 readonly upstream_revision="f217ece342dea3ef2a3f98671fcd16a39ad0037d"
1520
21+fail_unsafe_output() {
22+ echo "first-presentation: unsafe output path: $1" >&2
23+ exit 2
24+}
25+
26+# The artifact destination is caller-controlled. Never empty or recursively
27+# clear it: collection only writes to a newly-created directory whose parent is
28+# already canonical and contains no symbolic-link hop.
29+prepare_output() {
30+ local parent name canonical_parent canonical_output
31+
32+ [[ -n "$output" ]] || fail_unsafe_output "path must not be empty"
33+ [[ "$output" == /* ]] || fail_unsafe_output "path must be absolute"
34+ [[ "$output" != "/" ]] || fail_unsafe_output "path must not be /"
35+ [[ "${output%/}" != "${HOME%/}" ]] || fail_unsafe_output "path must not be HOME"
36+ [[ "$output" != .. && "$output" != ../* && "$output" != */.. && "$output" != */../* ]] || \
37+ fail_unsafe_output "path must not contain parent traversal"
38+ [[ ! -e "$output" && ! -L "$output" ]] || fail_unsafe_output "path must be a fresh directory"
39+
40+ parent="$(dirname -- "$output")"
41+ name="$(basename -- "$output")"
42+ [[ -n "$name" && "$name" != "." && "$name" != ".." ]] || fail_unsafe_output "invalid directory name"
43+ [[ -d "$parent" && ! -L "$parent" ]] || fail_unsafe_output "parent must be an existing directory"
44+ canonical_parent="$(realpath -e -- "$parent")" || fail_unsafe_output "parent cannot be canonicalized"
45+ [[ "$parent" == "$canonical_parent" ]] || fail_unsafe_output "parent must not contain symbolic links"
46+
47+ mkdir -- "$output" || fail_unsafe_output "could not create fresh directory"
48+ canonical_output="$(realpath -e -- "$output")" || fail_unsafe_output "created directory cannot be canonicalized"
49+ [[ -d "$output" && ! -L "$output" && "$canonical_output" == "$canonical_parent/$name" ]] || \
50+ fail_unsafe_output "created directory changed unexpectedly"
51+}
52+
53+prepare_output
54+
1655 [[ -x "$binary" ]] || { echo "first-presentation: executable not found: $binary" >&2; exit 2; }
1756 [[ -d "$runtime" ]] || { echo "first-presentation: CEF runtime not found: $runtime" >&2; exit 2; }
1857 [[ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]] || { echo "first-presentation: a current Wayland/X11 display is required" >&2; exit 2; }
@@ -21,12 +60,14 @@ readonly upstream_revision="f217ece342dea3ef2a3f98671fcd16a39ad0037d"
2160 exit 2
2261 }
2362
24-rm -rf "$output"
25-mkdir -p "$output"
2663 # Raw logs and temporary XDG homes may contain machine-local paths. Keep them
2764 # outside the committed artifact directory and always remove them.
28-work_root="$(mktemp -d "${TMPDIR:-/tmp}/dumber-first-presentation.XXXXXX")"
29-trap 'rm -rf "$work_root"' EXIT
65+readonly work_root="$(mktemp -d "${TMPDIR:-/tmp}/dumber-first-presentation.XXXXXX")"
66+cleanup_work_root() {
67+ [[ -n "${work_root:-}" && -d "$work_root" && ! -L "$work_root" ]] || return
68+ rm -rf -- "$work_root"
69+}
70+trap cleanup_work_root EXIT
3071 python3 - "$output/metadata.json" "$binary" "$timeout_seconds" "$upstream_module" "$upstream_tag" "$upstream_revision" <<'PY'
3172 import hashlib, json, os, subprocess, sys
3273 path, binary, timeout, upstream_module, upstream_tag, upstream_revision = sys.argv[1:]
@@ -8,11 +8,50 @@ readonly runs=5
8 readonly timeout_seconds="${DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS:-45}"8 readonly timeout_seconds="${DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS:-45}"
9 readonly runtime="${DUMBER_CEF_DIR:-$HOME/.local/share/cef-147-runtime}"9 readonly runtime="${DUMBER_CEF_DIR:-$HOME/.local/share/cef-147-runtime}"
10 readonly binary="${DUMBER_FIRST_PRESENTATION_BIN:-$PWD/dist/dumber}"10 readonly binary="${DUMBER_FIRST_PRESENTATION_BIN:-$PWD/dist/dumber}"
11-readonly output="${DUMBER_FIRST_PRESENTATION_OUTPUT:-$PWD/phase1/first-presentation}"11+if [[ -v DUMBER_FIRST_PRESENTATION_OUTPUT ]]; then
12+ output="$DUMBER_FIRST_PRESENTATION_OUTPUT"
13+else
14+ output="$PWD/phase1/first-presentation"
15+fi
16+readonly output
12 readonly upstream_module="github.com/bnema/purego-cef2gtk"17 readonly upstream_module="github.com/bnema/purego-cef2gtk"
13 readonly upstream_tag="v0.8.4"18 readonly upstream_tag="v0.8.4"
14 readonly upstream_revision="f217ece342dea3ef2a3f98671fcd16a39ad0037d"19 readonly upstream_revision="f217ece342dea3ef2a3f98671fcd16a39ad0037d"
15 20
21+fail_unsafe_output() {
22+ echo "first-presentation: unsafe output path: $1" >&2
23+ exit 2
24+}
25+
26+# The artifact destination is caller-controlled. Never empty or recursively
27+# clear it: collection only writes to a newly-created directory whose parent is
28+# already canonical and contains no symbolic-link hop.
29+prepare_output() {
30+ local parent name canonical_parent canonical_output
31+
32+ [[ -n "$output" ]] || fail_unsafe_output "path must not be empty"
33+ [[ "$output" == /* ]] || fail_unsafe_output "path must be absolute"
34+ [[ "$output" != "/" ]] || fail_unsafe_output "path must not be /"
35+ [[ "${output%/}" != "${HOME%/}" ]] || fail_unsafe_output "path must not be HOME"
36+ [[ "$output" != .. && "$output" != ../* && "$output" != */.. && "$output" != */../* ]] || \
37+ fail_unsafe_output "path must not contain parent traversal"
38+ [[ ! -e "$output" && ! -L "$output" ]] || fail_unsafe_output "path must be a fresh directory"
39+
40+ parent="$(dirname -- "$output")"
41+ name="$(basename -- "$output")"
42+ [[ -n "$name" && "$name" != "." && "$name" != ".." ]] || fail_unsafe_output "invalid directory name"
43+ [[ -d "$parent" && ! -L "$parent" ]] || fail_unsafe_output "parent must be an existing directory"
44+ canonical_parent="$(realpath -e -- "$parent")" || fail_unsafe_output "parent cannot be canonicalized"
45+ [[ "$parent" == "$canonical_parent" ]] || fail_unsafe_output "parent must not contain symbolic links"
46+
47+ mkdir -- "$output" || fail_unsafe_output "could not create fresh directory"
48+ canonical_output="$(realpath -e -- "$output")" || fail_unsafe_output "created directory cannot be canonicalized"
49+ [[ -d "$output" && ! -L "$output" && "$canonical_output" == "$canonical_parent/$name" ]] || \
50+ fail_unsafe_output "created directory changed unexpectedly"
51+}
52+
53+prepare_output
54+
16 [[ -x "$binary" ]] || { echo "first-presentation: executable not found: $binary" >&2; exit 2; }55 [[ -x "$binary" ]] || { echo "first-presentation: executable not found: $binary" >&2; exit 2; }
17 [[ -d "$runtime" ]] || { echo "first-presentation: CEF runtime not found: $runtime" >&2; exit 2; }56 [[ -d "$runtime" ]] || { echo "first-presentation: CEF runtime not found: $runtime" >&2; exit 2; }
18 [[ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]] || { echo "first-presentation: a current Wayland/X11 display is required" >&2; exit 2; }57 [[ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]] || { echo "first-presentation: a current Wayland/X11 display is required" >&2; exit 2; }
@@ -21,12 +60,14 @@ readonly upstream_revision="f217ece342dea3ef2a3f98671fcd16a39ad0037d"
21 exit 260 exit 2
22 }61 }
23 62
24-rm -rf "$output"
25-mkdir -p "$output"
26 # Raw logs and temporary XDG homes may contain machine-local paths. Keep them63 # Raw logs and temporary XDG homes may contain machine-local paths. Keep them
27 # outside the committed artifact directory and always remove them.64 # outside the committed artifact directory and always remove them.
28-work_root="$(mktemp -d "${TMPDIR:-/tmp}/dumber-first-presentation.XXXXXX")"65+readonly work_root="$(mktemp -d "${TMPDIR:-/tmp}/dumber-first-presentation.XXXXXX")"
29-trap 'rm -rf "$work_root"' EXIT66+cleanup_work_root() {
67+ [[ -n "${work_root:-}" && -d "$work_root" && ! -L "$work_root" ]] || return
68+ rm -rf -- "$work_root"
69+}
70+trap cleanup_work_root EXIT
30 python3 - "$output/metadata.json" "$binary" "$timeout_seconds" "$upstream_module" "$upstream_tag" "$upstream_revision" <<'PY'71 python3 - "$output/metadata.json" "$binary" "$timeout_seconds" "$upstream_module" "$upstream_tag" "$upstream_revision" <<'PY'
31 import hashlib, json, os, subprocess, sys72 import hashlib, json, os, subprocess, sys
32 path, binary, timeout, upstream_module, upstream_tag, upstream_revision = sys.argv[1:]73 path, binary, timeout, upstream_module, upstream_tag, upstream_revision = sys.argv[1:]

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 8

Tags 42

↑↓ moveenter openesc close