detached 98ea59f

docs(workflow): record what the github.com scenarios settled, replay the glob scenarios

commit
98ea59f9bdc5e6421d8f8ad4f2b2f0ccf20de628
author
brice · · 2026-10-12 19:18 UTC
committer
brice · · 2026-10-18 14:59 UTC
parent
21505b0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +122 -18
M workflow/PORTING.md +38 -14
@@ -40,23 +40,45 @@ code.
4040 (`success() && (cond)` when the condition calls none of `success`,
4141 `failure`, `cancelled`, `always`, looking through nested calls).
4242
43+## Recorded (github.com, ermite-conformance corpus)
44+
45+- Status functions (`success()`, `failure()`, `always()`, `cancelled()`) in
46+ `env` or `run` expressions make the workflow invalid with
47+ `Unrecognized function: 'success'. Located at position 1 within expression:
48+ success()` (expr-status-in-env, expr-status-in-run); `TestRecordedInvalidWorkflows`.
49+- Invalid workflow messages: `The workflow must contain at least one job with
50+ no dependencies.` (no root job: needs on an unknown job or a cycle with no
51+ other root), `Job 'x' depends on unknown job 'y'.`, `Job 'x' depends on job
52+ 'y' which creates a cycle in the dependency graph.`, `Required property is
53+ missing: runs-on`, `Unexpected value 'unknown-key'` (invalid-needs,
54+ invalid-schema). GitHub's recordings do not keep the text of the message; ours
55+ are the reference parser's, which the corpus shows refuses the same files.
56+
4357 ## To verify (recordings)
4458
4559 - [ ] **Glob syntax** (`glob.go`): `*`, `**`, `?`, `+`, `[]`, `\`, `!`. The
4660 cheat sheet's examples pass; `**/` matching nothing, `?` and `+` after a
4761 `]`, `*` or an escaped character, `[!x]`, an unclosed `[` (literal here)
4862 and `/`-prefixed patterns are assumed.
49-- [ ] **`MatchPush`**: deletes never run; more than 3 tags run nothing for
50- tags (not for the branch); branches-only filter and a tag push (no run)
51- and the reverse; with both filters each ref uses its own; `paths` only
52- for branches; first 300 files (also for `paths-ignore`); truncated list
53- passes; a push whose diff is empty never matches a `paths` filter and
54- always matches a `paths-ignore` one; unknown ref kinds never match.
63+- [x] **`MatchPush`** (recorded: push-tags-limit, filter-tags,
64+ filter-branches-ignore, filter-tag-branches-only, filter-paths-tag,
65+ filter-paths*, push-delete-*): deletes never run; more than 3 tags run
66+ nothing for tags (not for the branch); branches-only filter and a tag
67+ push (no run) and the reverse; with both filters each ref uses its own;
68+ `paths` only for branches; the first **3000** files, also for
69+ `paths-ignore` (not 300). Still assumed: a push whose diff is empty
70+ never matches a `paths` filter and always matches a `paths-ignore` one;
71+ unknown ref kinds never match; truncated list passes.
5572 - [ ] **`PushInfo.Created`** is not used: a new branch's file list is the
5673 caller's job (diff against the default branch merge-base).
57-- [ ] **`SkipCI`**: marker case, trailer rules (see its doc comment); whether
58- only the head commit counts; `[skip ci]` in the subject vs the body.
59-- [ ] **`ExpandMatrix`**: order of the product (first key slowest), include
74+- [x] **`SkipCI`** (recorded: skip-directives, skip-directives-placement,
75+ skip-checks-trailer, skip-directive-tag): markers are case-sensitive,
76+ anywhere; `skip-checks: true` needs two blank lines before it and
77+ nothing after it, in exact case. The tag case is the caller's: the
78+ message of the commit the tag points at. Only the head commit counts.
79+- [ ] **`ExpandMatrix`** (recorded: the unknown exclude key, once per
80+ offending entry, and the 256 limit message, see
81+ `ConfigurationLimitError`; matrix-edge-values, matrix-over-256): order of the product (first key slowest), include
6082 semantics (added to every matching combination, never overwriting
6183 original values, later includes overwrite earlier added values, a
6284 non-matching include is a new combination, match by vector keys only),
@@ -64,18 +86,20 @@ code.
6486 combinations after include, key case-insensitivity, value comparison
6587 (same kind; objects by containment), the error when all combinations are
6688 excluded (GitHub may fail the run differently), empty vector error.
67-- [ ] A **null matrix value** is shown as an empty segment (`job (, 1)`);
68- GitHub may skip it.
69-- [ ] **Job display names** (`MatrixCombo.DisplayName`, `Job.DisplayName`):
89+- [x] A **null or empty matrix value** is not shown in the job name
90+ (recorded: matrix-edge-values).
91+- [x] **Job display names** (`MatrixCombo.DisplayName`, `Job.DisplayName`;
92+ recorded: matrix-job-name, matrix-edge-values):
7093 `name (v1, v2)` listing every scalar value of the combination in key
7194 order, then keys added by include; a name that reads the `matrix`
7295 context (found in the expression's syntax tree, not by searching the
73- text) is used as is. Booleans/numbers/null are shown with `CoerceString`.
96+ text) is used as is. Booleans and numbers are shown with `CoerceString`; null, `''`, arrays
97+ and objects are left out.
7498 - [ ] **`strategy` context** (`Strategy.Context`): `fail-fast`, `job-index`,
7599 `job-total`, `max-parallel` (the job count when unlimited).
76100 - [ ] `fail-fast` and `max-parallel` accept strings (`"false"`, `"2"`) after
77101 evaluation; `max-parallel` must be a whole number ≥ 1.
78-- [ ] The 1 000-commit and 300-file limits themselves are the caller's.
102+- [ ] The 1 000-commit and 3 000-file limits themselves are the caller's.
79103
80104 ## New in the second pass
81105
@@ -40,23 +40,45 @@ code.
40 (`success() && (cond)` when the condition calls none of `success`,40 (`success() && (cond)` when the condition calls none of `success`,
41 `failure`, `cancelled`, `always`, looking through nested calls).41 `failure`, `cancelled`, `always`, looking through nested calls).
42 42
43+## Recorded (github.com, ermite-conformance corpus)
44+
45+- Status functions (`success()`, `failure()`, `always()`, `cancelled()`) in
46+ `env` or `run` expressions make the workflow invalid with
47+ `Unrecognized function: 'success'. Located at position 1 within expression:
48+ success()` (expr-status-in-env, expr-status-in-run); `TestRecordedInvalidWorkflows`.
49+- Invalid workflow messages: `The workflow must contain at least one job with
50+ no dependencies.` (no root job: needs on an unknown job or a cycle with no
51+ other root), `Job 'x' depends on unknown job 'y'.`, `Job 'x' depends on job
52+ 'y' which creates a cycle in the dependency graph.`, `Required property is
53+ missing: runs-on`, `Unexpected value 'unknown-key'` (invalid-needs,
54+ invalid-schema). GitHub's recordings do not keep the text of the message; ours
55+ are the reference parser's, which the corpus shows refuses the same files.
56+
43 ## To verify (recordings)57 ## To verify (recordings)
44 58
45 - [ ] **Glob syntax** (`glob.go`): `*`, `**`, `?`, `+`, `[]`, `\`, `!`. The59 - [ ] **Glob syntax** (`glob.go`): `*`, `**`, `?`, `+`, `[]`, `\`, `!`. The
46 cheat sheet's examples pass; `**/` matching nothing, `?` and `+` after a60 cheat sheet's examples pass; `**/` matching nothing, `?` and `+` after a
47 `]`, `*` or an escaped character, `[!x]`, an unclosed `[` (literal here)61 `]`, `*` or an escaped character, `[!x]`, an unclosed `[` (literal here)
48 and `/`-prefixed patterns are assumed.62 and `/`-prefixed patterns are assumed.
49-- [ ] **`MatchPush`**: deletes never run; more than 3 tags run nothing for63+- [x] **`MatchPush`** (recorded: push-tags-limit, filter-tags,
50- tags (not for the branch); branches-only filter and a tag push (no run)64+ filter-branches-ignore, filter-tag-branches-only, filter-paths-tag,
51- and the reverse; with both filters each ref uses its own; `paths` only65+ filter-paths*, push-delete-*): deletes never run; more than 3 tags run
52- for branches; first 300 files (also for `paths-ignore`); truncated list66+ nothing for tags (not for the branch); branches-only filter and a tag
53- passes; a push whose diff is empty never matches a `paths` filter and67+ push (no run) and the reverse; with both filters each ref uses its own;
54- always matches a `paths-ignore` one; unknown ref kinds never match.68+ `paths` only for branches; the first **3000** files, also for
69+ `paths-ignore` (not 300). Still assumed: a push whose diff is empty
70+ never matches a `paths` filter and always matches a `paths-ignore` one;
71+ unknown ref kinds never match; truncated list passes.
55 - [ ] **`PushInfo.Created`** is not used: a new branch's file list is the72 - [ ] **`PushInfo.Created`** is not used: a new branch's file list is the
56 caller's job (diff against the default branch merge-base).73 caller's job (diff against the default branch merge-base).
57-- [ ] **`SkipCI`**: marker case, trailer rules (see its doc comment); whether74+- [x] **`SkipCI`** (recorded: skip-directives, skip-directives-placement,
58- only the head commit counts; `[skip ci]` in the subject vs the body.75+ skip-checks-trailer, skip-directive-tag): markers are case-sensitive,
59-- [ ] **`ExpandMatrix`**: order of the product (first key slowest), include76+ anywhere; `skip-checks: true` needs two blank lines before it and
77+ nothing after it, in exact case. The tag case is the caller's: the
78+ message of the commit the tag points at. Only the head commit counts.
79+- [ ] **`ExpandMatrix`** (recorded: the unknown exclude key, once per
80+ offending entry, and the 256 limit message, see
81+ `ConfigurationLimitError`; matrix-edge-values, matrix-over-256): order of the product (first key slowest), include
60 semantics (added to every matching combination, never overwriting82 semantics (added to every matching combination, never overwriting
61 original values, later includes overwrite earlier added values, a83 original values, later includes overwrite earlier added values, a
62 non-matching include is a new combination, match by vector keys only),84 non-matching include is a new combination, match by vector keys only),
@@ -64,18 +86,20 @@ code.
64 combinations after include, key case-insensitivity, value comparison86 combinations after include, key case-insensitivity, value comparison
65 (same kind; objects by containment), the error when all combinations are87 (same kind; objects by containment), the error when all combinations are
66 excluded (GitHub may fail the run differently), empty vector error.88 excluded (GitHub may fail the run differently), empty vector error.
67-- [ ] A **null matrix value** is shown as an empty segment (`job (, 1)`);89+- [x] A **null or empty matrix value** is not shown in the job name
68- GitHub may skip it.90+ (recorded: matrix-edge-values).
69-- [ ] **Job display names** (`MatrixCombo.DisplayName`, `Job.DisplayName`):91+- [x] **Job display names** (`MatrixCombo.DisplayName`, `Job.DisplayName`;
92+ recorded: matrix-job-name, matrix-edge-values):
70 `name (v1, v2)` listing every scalar value of the combination in key93 `name (v1, v2)` listing every scalar value of the combination in key
71 order, then keys added by include; a name that reads the `matrix`94 order, then keys added by include; a name that reads the `matrix`
72 context (found in the expression's syntax tree, not by searching the95 context (found in the expression's syntax tree, not by searching the
73- text) is used as is. Booleans/numbers/null are shown with `CoerceString`.96+ text) is used as is. Booleans and numbers are shown with `CoerceString`; null, `''`, arrays
97+ and objects are left out.
74 - [ ] **`strategy` context** (`Strategy.Context`): `fail-fast`, `job-index`,98 - [ ] **`strategy` context** (`Strategy.Context`): `fail-fast`, `job-index`,
75 `job-total`, `max-parallel` (the job count when unlimited).99 `job-total`, `max-parallel` (the job count when unlimited).
76 - [ ] `fail-fast` and `max-parallel` accept strings (`"false"`, `"2"`) after100 - [ ] `fail-fast` and `max-parallel` accept strings (`"false"`, `"2"`) after
77 evaluation; `max-parallel` must be a whole number ≥ 1.101 evaluation; `max-parallel` must be a whole number ≥ 1.
78-- [ ] The 1 000-commit and 300-file limits themselves are the caller's.102+- [ ] The 1 000-commit and 3 000-file limits themselves are the caller's.
79 103
80 ## New in the second pass104 ## New in the second pass
81 105
M workflow/glob.go +5 -4
@@ -24,10 +24,11 @@ import (
2424 // Every other character, "." included, stands for itself. Matching is
2525 // case-sensitive and covers the whole name.
2626 //
27-// TODO(verify with recording): the exact GitHub implementation is not
28-// public. The cheat sheet fixes the common cases; "**/" matching nothing,
29-// "\" escapes and how ? and + behave after a wildcard or a bracket are
30-// reconstructed.
27+// Recorded on github.com (filter-branches, filter-paths, filter-paths-ignore,
28+// see TestMatchPushRecordedGlobs): "*", "**", "scn/filter-*/**", "**.md",
29+// "*.md" and negation order. TODO(verify with recording): "**/" matching
30+// nothing, "\" escapes and how ? and + behave after a wildcard or a bracket
31+// are reconstructed.
3132 type Pattern struct {
3233 // Negated is set for a pattern that started with "!".
3334 Negated bool
@@ -24,10 +24,11 @@ import (
24 // Every other character, "." included, stands for itself. Matching is24 // Every other character, "." included, stands for itself. Matching is
25 // case-sensitive and covers the whole name.25 // case-sensitive and covers the whole name.
26 //26 //
27-// TODO(verify with recording): the exact GitHub implementation is not27+// Recorded on github.com (filter-branches, filter-paths, filter-paths-ignore,
28-// public. The cheat sheet fixes the common cases; "**/" matching nothing,28+// see TestMatchPushRecordedGlobs): "*", "**", "scn/filter-*/**", "**.md",
29-// "\" escapes and how ? and + behave after a wildcard or a bracket are29+// "*.md" and negation order. TODO(verify with recording): "**/" matching
30-// reconstructed.30+// nothing, "\" escapes and how ? and + behave after a wildcard or a bracket
31+// are reconstructed.
31 type Pattern struct {32 type Pattern struct {
32 // Negated is set for a pattern that started with "!".33 // Negated is set for a pattern that started with "!".
33 Negated bool34 Negated bool
M workflow/push_test.go +79 -0
@@ -226,6 +226,85 @@ func TestMatchPushRecordedTags(t *testing.T) {
226226 }
227227 }
228228
229+// TestMatchPushRecordedGlobs replays filter-branches (branch
230+// scn/filter-branches/<run>: ran for b-match, b-negated-then-readded,
231+// b-prefix-only, b-star-star and b-two-levels; not for b-negated, b-one-level
232+// and b-other), filter-paths and filter-paths-ignore.
233+func TestMatchPushRecordedGlobs(t *testing.T) {
234+ br := PushInfo{Ref: "refs/heads/scn/filter-branches/1"}
235+ for _, tc := range []struct {
236+ name, filter string
237+ want bool
238+ }{
239+ {"b-match", "branches: ['scn/filter-branches/**']", true},
240+ {"b-negated-then-readded", "branches: ['**', '!scn/**', 'scn/filter-branches/**']", true},
241+ {"b-prefix-only", "branches: ['scn/filter-*/**']", true},
242+ {"b-star-star", "branches: ['**']", true},
243+ {"b-two-levels", "branches: ['scn/*/*']", true},
244+ {"b-negated", "branches: ['scn/**', '!scn/filter-branches/**']", false},
245+ {"b-one-level", "branches: ['scn/*']", false},
246+ {"b-other", "branches: [main, 'release/**']", false},
247+ } {
248+ if got := pushWorkflow(t, "on:\n push:\n "+tc.filter).MatchPush(br); got != tc.want {
249+ t.Errorf("filter-branches %s: %v, want %v", tc.name, got, tc.want)
250+ }
251+ }
252+
253+ // filter-paths: the files of each push.
254+ pushes := [][]string{
255+ {".github/workflows/p-docs.yml", "docs/readme.md", "other/x.txt", "src/a.txt", "top.md"}, // 1: the workflow files are new too
256+ {"docs/readme.md"}, // 2
257+ {"src/a.txt"}, // 3
258+ {"top.md"}, // 4
259+ {"docs/new.md", "other/x.txt"}, // 5
260+ }
261+ for _, tc := range []struct {
262+ name, filter string
263+ ran [5]bool
264+ }{
265+ {"p-docs", "paths: ['docs/**']", [5]bool{true, true, false, false, true}},
266+ {"p-double-star-md", "paths: ['**.md']", [5]bool{true, true, false, true, true}},
267+ {"p-exact-file", "paths: ['src/a.txt']", [5]bool{true, false, true, false, false}},
268+ {"p-negated", "paths: ['**', '!docs/**']", [5]bool{true, false, true, true, true}},
269+ {"p-no-match", "paths: ['nothing/**']", [5]bool{}},
270+ {"p-single-star-md", "paths: ['*.md']", [5]bool{true, false, false, true, false}},
271+ {"p-src", "paths: ['src/**']", [5]bool{true, false, true, false, false}},
272+ } {
273+ w := pushWorkflow(t, "on:\n push:\n branches: ['scn/**']\n "+tc.filter)
274+ for n, files := range pushes {
275+ got := w.MatchPush(PushInfo{Ref: "refs/heads/scn/filter-paths/1", ChangedFiles: files})
276+ if got != tc.ran[n] {
277+ t.Errorf("filter-paths %s push %d: %v, want %v", tc.name, n+1, got, tc.ran[n])
278+ }
279+ }
280+ }
281+
282+ // filter-paths-ignore: pushes 1 to 4 changed these files.
283+ ignorePushes := [][]string{
284+ {".github/workflows/pi-docs.yml", "docs/readme.md", "src/a.txt"}, // 1: the workflow files are new too
285+ {"docs/readme.md"}, // 2
286+ {"docs/readme.md", "src/a.txt"}, // 3
287+ {"src/a.txt"}, // 4
288+ }
289+ for _, tc := range []struct {
290+ name, filter string
291+ ran [4]bool
292+ }{
293+ {"pi-docs", "paths-ignore: ['docs/**']", [4]bool{true, false, true, true}},
294+ {"pi-md", "paths-ignore: ['**.md']", [4]bool{true, false, true, true}},
295+ {"pi-src-docs", "paths-ignore: ['docs/**', 'src/**']", [4]bool{true, false, false, false}},
296+ {"pi-everything", "paths-ignore: ['**']", [4]bool{}},
297+ } {
298+ w := pushWorkflow(t, "on:\n push:\n branches: ['scn/**']\n "+tc.filter)
299+ for n, files := range ignorePushes {
300+ got := w.MatchPush(PushInfo{Ref: "refs/heads/scn/filter-paths-ignore/1", ChangedFiles: files})
301+ if got != tc.ran[n] {
302+ t.Errorf("filter-paths-ignore %s push %d: %v, want %v", tc.name, n+1, got, tc.ran[n])
303+ }
304+ }
305+ }
306+}
307+
229308 func TestMatchPushNilSafe(t *testing.T) {
230309 var w *Workflow
231310 if w.MatchPush(PushInfo{Ref: "refs/heads/main"}) {
@@ -226,6 +226,85 @@ func TestMatchPushRecordedTags(t *testing.T) {
226 }226 }
227 }227 }
228 228
229+// TestMatchPushRecordedGlobs replays filter-branches (branch
230+// scn/filter-branches/<run>: ran for b-match, b-negated-then-readded,
231+// b-prefix-only, b-star-star and b-two-levels; not for b-negated, b-one-level
232+// and b-other), filter-paths and filter-paths-ignore.
233+func TestMatchPushRecordedGlobs(t *testing.T) {
234+ br := PushInfo{Ref: "refs/heads/scn/filter-branches/1"}
235+ for _, tc := range []struct {
236+ name, filter string
237+ want bool
238+ }{
239+ {"b-match", "branches: ['scn/filter-branches/**']", true},
240+ {"b-negated-then-readded", "branches: ['**', '!scn/**', 'scn/filter-branches/**']", true},
241+ {"b-prefix-only", "branches: ['scn/filter-*/**']", true},
242+ {"b-star-star", "branches: ['**']", true},
243+ {"b-two-levels", "branches: ['scn/*/*']", true},
244+ {"b-negated", "branches: ['scn/**', '!scn/filter-branches/**']", false},
245+ {"b-one-level", "branches: ['scn/*']", false},
246+ {"b-other", "branches: [main, 'release/**']", false},
247+ } {
248+ if got := pushWorkflow(t, "on:\n push:\n "+tc.filter).MatchPush(br); got != tc.want {
249+ t.Errorf("filter-branches %s: %v, want %v", tc.name, got, tc.want)
250+ }
251+ }
252+
253+ // filter-paths: the files of each push.
254+ pushes := [][]string{
255+ {".github/workflows/p-docs.yml", "docs/readme.md", "other/x.txt", "src/a.txt", "top.md"}, // 1: the workflow files are new too
256+ {"docs/readme.md"}, // 2
257+ {"src/a.txt"}, // 3
258+ {"top.md"}, // 4
259+ {"docs/new.md", "other/x.txt"}, // 5
260+ }
261+ for _, tc := range []struct {
262+ name, filter string
263+ ran [5]bool
264+ }{
265+ {"p-docs", "paths: ['docs/**']", [5]bool{true, true, false, false, true}},
266+ {"p-double-star-md", "paths: ['**.md']", [5]bool{true, true, false, true, true}},
267+ {"p-exact-file", "paths: ['src/a.txt']", [5]bool{true, false, true, false, false}},
268+ {"p-negated", "paths: ['**', '!docs/**']", [5]bool{true, false, true, true, true}},
269+ {"p-no-match", "paths: ['nothing/**']", [5]bool{}},
270+ {"p-single-star-md", "paths: ['*.md']", [5]bool{true, false, false, true, false}},
271+ {"p-src", "paths: ['src/**']", [5]bool{true, false, true, false, false}},
272+ } {
273+ w := pushWorkflow(t, "on:\n push:\n branches: ['scn/**']\n "+tc.filter)
274+ for n, files := range pushes {
275+ got := w.MatchPush(PushInfo{Ref: "refs/heads/scn/filter-paths/1", ChangedFiles: files})
276+ if got != tc.ran[n] {
277+ t.Errorf("filter-paths %s push %d: %v, want %v", tc.name, n+1, got, tc.ran[n])
278+ }
279+ }
280+ }
281+
282+ // filter-paths-ignore: pushes 1 to 4 changed these files.
283+ ignorePushes := [][]string{
284+ {".github/workflows/pi-docs.yml", "docs/readme.md", "src/a.txt"}, // 1: the workflow files are new too
285+ {"docs/readme.md"}, // 2
286+ {"docs/readme.md", "src/a.txt"}, // 3
287+ {"src/a.txt"}, // 4
288+ }
289+ for _, tc := range []struct {
290+ name, filter string
291+ ran [4]bool
292+ }{
293+ {"pi-docs", "paths-ignore: ['docs/**']", [4]bool{true, false, true, true}},
294+ {"pi-md", "paths-ignore: ['**.md']", [4]bool{true, false, true, true}},
295+ {"pi-src-docs", "paths-ignore: ['docs/**', 'src/**']", [4]bool{true, false, false, false}},
296+ {"pi-everything", "paths-ignore: ['**']", [4]bool{}},
297+ } {
298+ w := pushWorkflow(t, "on:\n push:\n branches: ['scn/**']\n "+tc.filter)
299+ for n, files := range ignorePushes {
300+ got := w.MatchPush(PushInfo{Ref: "refs/heads/scn/filter-paths-ignore/1", ChangedFiles: files})
301+ if got != tc.ran[n] {
302+ t.Errorf("filter-paths-ignore %s push %d: %v, want %v", tc.name, n+1, got, tc.ran[n])
303+ }
304+ }
305+ }
306+}
307+
229 func TestMatchPushNilSafe(t *testing.T) {308 func TestMatchPushNilSafe(t *testing.T) {
230 var w *Workflow309 var w *Workflow
231 if w.MatchPush(PushInfo{Ref: "refs/heads/main"}) {310 if w.MatchPush(PushInfo{Ref: "refs/heads/main"}) {

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