detached a52e9b5

build(gen): pin the reference packages, regenerate their answers from a clean npm ci

commit
a52e9b5cddd6dfccb870cf440148f6dddd27cd61
author
brice · · 2026-10-12 16:15 UTC
committer
brice · · 2026-10-12 16:17 UTC
parent
fa5b491
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +172 -9
A gen/.gitignore +1 -0
@@ -0,0 +1,1 @@
1+node_modules/
@@ -0,0 +1,1 @@
1+node_modules/
A gen/json-hook.mjs +8 -0
@@ -0,0 +1,8 @@
1+// SPDX-License-Identifier: MIT
2+
3+export async function load(url, context, nextLoad) {
4+ if (url.endsWith(".json") && !context.importAttributes?.type) {
5+ return nextLoad(url, { ...context, importAttributes: { ...context.importAttributes, type: "json" } });
6+ }
7+ return nextLoad(url, context);
8+}
@@ -0,0 +1,8 @@
1+// SPDX-License-Identifier: MIT
2+
3+export async function load(url, context, nextLoad) {
4+ if (url.endsWith(".json") && !context.importAttributes?.type) {
5+ return nextLoad(url, { ...context, importAttributes: { ...context.importAttributes, type: "json" } });
6+ }
7+ return nextLoad(url, context);
8+}
A gen/package-lock.json +65 -0 generated file: collapsed
A gen/package.json +13 -0
@@ -0,0 +1,13 @@
1+{
2+ "name": "ermite-actions-gen",
3+ "private": true,
4+ "description": "Pinned reference implementations that the generators in expr/gen, template/gen and workflow/gen run. Not shipped.",
5+ "license": "MIT",
6+ "type": "module",
7+ "dependencies": {
8+ "@actions/expressions": "0.3.61",
9+ "@actions/workflow-parser": "0.3.61",
10+ "cronstrue": "2.61.0",
11+ "yaml": "2.8.3"
12+ }
13+}
@@ -0,0 +1,13 @@
1+{
2+ "name": "ermite-actions-gen",
3+ "private": true,
4+ "description": "Pinned reference implementations that the generators in expr/gen, template/gen and workflow/gen run. Not shipped.",
5+ "license": "MIT",
6+ "type": "module",
7+ "dependencies": {
8+ "@actions/expressions": "0.3.61",
9+ "@actions/workflow-parser": "0.3.61",
10+ "cronstrue": "2.61.0",
11+ "yaml": "2.8.3"
12+ }
13+}
A gen/regen.sh +18 -0
@@ -0,0 +1,18 @@
1+#!/bin/sh
2+# SPDX-License-Identifier: MIT
3+#
4+# Regenerates every file that is the answer of a reference implementation:
5+# workflow/testdata/convert.json (@actions/workflow-parser)
6+# template/testdata/scalars.json (yaml)
7+# from the exact versions pinned in gen/package-lock.json, installed with
8+# `npm ci`. Run from anywhere; `git diff` afterwards must be empty unless the
9+# pins or the corpus changed. (expr/gen has its own generators, run the same
10+# way with `--import ./gen/register.mjs` and the dist path of
11+# gen/node_modules/@actions/expressions.)
12+set -eu
13+root=$(cd "$(dirname "$0")/.." && pwd)
14+cd "$root/gen"
15+npm ci --ignore-scripts --no-audit --no-fund
16+cd "$root"
17+node --import ./gen/register.mjs workflow/gen/convert.mjs > workflow/testdata/convert.json
18+node --import ./gen/register.mjs template/gen/scalars.mjs > template/testdata/scalars.json
@@ -0,0 +1,18 @@
1+#!/bin/sh
2+# SPDX-License-Identifier: MIT
3+#
4+# Regenerates every file that is the answer of a reference implementation:
5+# workflow/testdata/convert.json (@actions/workflow-parser)
6+# template/testdata/scalars.json (yaml)
7+# from the exact versions pinned in gen/package-lock.json, installed with
8+# `npm ci`. Run from anywhere; `git diff` afterwards must be empty unless the
9+# pins or the corpus changed. (expr/gen has its own generators, run the same
10+# way with `--import ./gen/register.mjs` and the dist path of
11+# gen/node_modules/@actions/expressions.)
12+set -eu
13+root=$(cd "$(dirname "$0")/.." && pwd)
14+cd "$root/gen"
15+npm ci --ignore-scripts --no-audit --no-fund
16+cd "$root"
17+node --import ./gen/register.mjs workflow/gen/convert.mjs > workflow/testdata/convert.json
18+node --import ./gen/register.mjs template/gen/scalars.mjs > template/testdata/scalars.json
A gen/register.mjs +10 -0
@@ -0,0 +1,10 @@
1+// SPDX-License-Identifier: MIT
2+//
3+// Node 22+ refuses to import a .json file without `with { type: "json" }`,
4+// which @actions/workflow-parser 0.3.61 does for workflow-v1.0.min.json. This
5+// loader hook adds the attribute so that the package can run unmodified:
6+//
7+// node --import ./gen/register.mjs workflow/gen/convert.mjs
8+import { register } from "node:module";
9+
10+register("./json-hook.mjs", import.meta.url);
@@ -0,0 +1,10 @@
1+// SPDX-License-Identifier: MIT
2+//
3+// Node 22+ refuses to import a .json file without `with { type: "json" }`,
4+// which @actions/workflow-parser 0.3.61 does for workflow-v1.0.min.json. This
5+// loader hook adds the attribute so that the package can run unmodified:
6+//
7+// node --import ./gen/register.mjs workflow/gen/convert.mjs
8+import { register } from "node:module";
9+
10+register("./json-hook.mjs", import.meta.url);
M template/PORTING.md +17 -0
@@ -11,6 +11,19 @@ replays them (messages, line and column included).
1111 The C# runner (`ObjectTemplating`) is what runs on github.com. It was not
1212 available. Nothing here has been checked against it.
1313
14+## Reference versions and regeneration
15+
16+The answers in `../workflow/testdata/convert.json` and `testdata/scalars.json`
17+come from the versions pinned in `../gen/package.json` and
18+`../gen/package-lock.json`: `@actions/workflow-parser` 0.3.61,
19+`@actions/expressions` 0.3.61, `yaml` 2.8.3, `cronstrue` 2.61.0 (only imported
20+by the parser's cron description helper, which is not used here). Run
21+`../gen/regen.sh`: it does an `npm ci` and rewrites both files; `git diff`
22+must show nothing. The loader hook `../gen/register.mjs` adds the
23+`with { type: "json" }` import attribute that Node 22+ requires and the
24+parser omits, so the packages run unmodified. The last check, on a clean
25+`npm ci`, gave identical files.
26+
1427 ## Differences from the reference
1528
1629 - **YAML parser.** The reference uses the JavaScript `yaml` library (YAML 1.2,
@@ -38,6 +51,10 @@ available. Nothing here has been checked against it.
3851 messages use too (checked on `multiline_*`, `expr_multi`).
3952 `BasicExpression.Source` is the value of the scalar, not the raw source text
4053 (so a block scalar's indentation is not in it).
54+- **File size.** A document over `MaxFileBytes` (1 MiB) is refused before any
55+ parsing, with `file.yml: The file is too large: N bytes, the limit is M
56+ bytes` (our wording; the reference has no limit, GitHub has its own).
57+ TODO(verify with recording).
4158 - **Error limit.** `Context` keeps at most `MaxErrors` (1 000) errors.
4259 - **Limits on evaluation.** `MaxEvalDepth` (100) nested tokens; the result
4360 budget of package expr applies per expression.
@@ -11,6 +11,19 @@ replays them (messages, line and column included).
11 The C# runner (`ObjectTemplating`) is what runs on github.com. It was not11 The C# runner (`ObjectTemplating`) is what runs on github.com. It was not
12 available. Nothing here has been checked against it.12 available. Nothing here has been checked against it.
13 13
14+## Reference versions and regeneration
15+
16+The answers in `../workflow/testdata/convert.json` and `testdata/scalars.json`
17+come from the versions pinned in `../gen/package.json` and
18+`../gen/package-lock.json`: `@actions/workflow-parser` 0.3.61,
19+`@actions/expressions` 0.3.61, `yaml` 2.8.3, `cronstrue` 2.61.0 (only imported
20+by the parser's cron description helper, which is not used here). Run
21+`../gen/regen.sh`: it does an `npm ci` and rewrites both files; `git diff`
22+must show nothing. The loader hook `../gen/register.mjs` adds the
23+`with { type: "json" }` import attribute that Node 22+ requires and the
24+parser omits, so the packages run unmodified. The last check, on a clean
25+`npm ci`, gave identical files.
26+
14 ## Differences from the reference27 ## Differences from the reference
15 28
16 - **YAML parser.** The reference uses the JavaScript `yaml` library (YAML 1.2,29 - **YAML parser.** The reference uses the JavaScript `yaml` library (YAML 1.2,
@@ -38,6 +51,10 @@ available. Nothing here has been checked against it.
38 messages use too (checked on `multiline_*`, `expr_multi`).51 messages use too (checked on `multiline_*`, `expr_multi`).
39 `BasicExpression.Source` is the value of the scalar, not the raw source text52 `BasicExpression.Source` is the value of the scalar, not the raw source text
40 (so a block scalar's indentation is not in it).53 (so a block scalar's indentation is not in it).
54+- **File size.** A document over `MaxFileBytes` (1 MiB) is refused before any
55+ parsing, with `file.yml: The file is too large: N bytes, the limit is M
56+ bytes` (our wording; the reference has no limit, GitHub has its own).
57+ TODO(verify with recording).
41 - **Error limit.** `Context` keeps at most `MaxErrors` (1 000) errors.58 - **Error limit.** `Context` keeps at most `MaxErrors` (1 000) errors.
42 - **Limits on evaluation.** `MaxEvalDepth` (100) nested tokens; the result59 - **Limits on evaluation.** `MaxEvalDepth` (100) nested tokens; the result
43 budget of package expr applies per expression.60 budget of package expr applies per expression.
M template/gen/scalars.mjs +5 -2
@@ -3,9 +3,12 @@
33 // Generates testdata/scalars.json: how the JavaScript `yaml` library (2.x, the
44 // one @actions/workflow-parser uses) types plain scalars.
55 //
6-// node gen/scalars.mjs > testdata/scalars.json
6+// gen/regen.sh
77
8-import {parseDocument} from "yaml";
8+import { createRequire } from "node:module";
9+
10+// The yaml package pinned in gen/package.json.
11+const { parseDocument } = createRequire(new URL("../../gen/package.json", import.meta.url))("yaml");
912 const vals = ["yes","no","on","off","y","n","~","null","Null","NULL","","true","True","TRUE","tRUE","false","0","-0","+1","017","0o17","0O17","0x1F","0X1f","0b11","1_000","1:30","1e3","1E3","1.","1.5","-.5",".5","+.5",".inf","-.inf",".Inf",".INF","+.inf",".nan",".NaN",".NAN","-.nan","1e","e1","0x","0xg","12abc","2001-01-01","1.5e+3","0.","00","08","-017","+0x1F","-0x1F","1,000","Infinity","NaN","inf","=","<<","!!str 1","!!int 5"];
1013 const out = {};
1114 for (const v of vals) {
@@ -3,9 +3,12 @@
3 // Generates testdata/scalars.json: how the JavaScript `yaml` library (2.x, the3 // Generates testdata/scalars.json: how the JavaScript `yaml` library (2.x, the
4 // one @actions/workflow-parser uses) types plain scalars.4 // one @actions/workflow-parser uses) types plain scalars.
5 //5 //
6-// node gen/scalars.mjs > testdata/scalars.json6+// gen/regen.sh
7 7
8-import {parseDocument} from "yaml";8+import { createRequire } from "node:module";
9+
10+// The yaml package pinned in gen/package.json.
11+const { parseDocument } = createRequire(new URL("../../gen/package.json", import.meta.url))("yaml");
9 const vals = ["yes","no","on","off","y","n","~","null","Null","NULL","","true","True","TRUE","tRUE","false","0","-0","+1","017","0o17","0O17","0x1F","0X1f","0b11","1_000","1:30","1e3","1E3","1.","1.5","-.5",".5","+.5",".inf","-.inf",".Inf",".INF","+.inf",".nan",".NaN",".NAN","-.nan","1e","e1","0x","0xg","12abc","2001-01-01","1.5e+3","0.","00","08","-017","+0x1F","-0x1F","1,000","Infinity","NaN","inf","=","<<","!!str 1","!!int 5"];12 const vals = ["yes","no","on","off","y","n","~","null","Null","NULL","","true","True","TRUE","tRUE","false","0","-0","+1","017","0o17","0O17","0x1F","0X1f","0b11","1_000","1:30","1e3","1E3","1.","1.5","-.5",".5","+.5",".inf","-.inf",".Inf",".INF","+.inf",".nan",".NaN",".NAN","-.nan","1e","e1","0x","0xg","12abc","2001-01-01","1.5e+3","0.","00","08","-017","+0x1F","-0x1F","1,000","Infinity","NaN","inf","=","<<","!!str 1","!!int 5"];
10 const out = {};13 const out = {};
11 for (const v of vals) {14 for (const v of vals) {
M workflow/PORTING.md +29 -2
@@ -18,6 +18,20 @@ code.
1818 accepts them; GitHub refuses the file. `Parse` reports
1919 `Event 'push' cannot use both 'branches' and 'branches-ignore'. Use only one
2020 of them.` (our wording, to replace with GitHub's). Applied to every event.
21+- **A filter list made only of `!` patterns** (`branches: ['!main']`) is an
22+ error (`Event 'push' filter 'branches' has only patterns that start with
23+ '!'...`), and so is a pattern that does not compile. Both are ours; GitHub's
24+ texts are unknown. Applied to every event and every filter.
25+- **Matrix exclude** with a key that is not a vector of the matrix is
26+ `Matrix exclude key '<k>' does not match any key within the matrix`
27+ (GitHub's text as remembered, not recorded).
28+- **Limits that GitHub does not state**: the cross product of the vectors may
29+ not exceed `MaxMatrixProduct` (65 536) before exclusions, include and exclude
30+ have at most `MaxMatrixEntries` (256) entries each, and the context is
31+ checked every 1 024 combinations. They keep a hostile matrix from running
32+ for long; a legitimate one (at most 256 jobs) is far below them. The
33+ product limit also refuses a matrix GitHub would accept by excluding most
34+ of a huge product.
2135 - **Reusable workflows** are parsed (`uses`, `with`, `secrets`, `inherit`) but
2236 not followed, and the called workflow's inputs and secrets are not checked.
2337 - Unknown event types are reported by the schema (`Unexpected value 'x'`).
@@ -50,12 +64,25 @@ code.
5064 combinations after include, key case-insensitivity, value comparison
5165 (same kind; objects by containment), the error when all combinations are
5266 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.
5369 - [ ] **Job display names** (`MatrixCombo.DisplayName`, `Job.DisplayName`):
5470 `name (v1, v2)` listing every scalar value of the combination in key
55- order, then keys added by include; a name that refers to `matrix.` is
56- used as is. Booleans/numbers/null are shown with `CoerceString`.
71+ order, then keys added by include; a name that reads the `matrix`
72+ 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`.
5774 - [ ] **`strategy` context** (`Strategy.Context`): `fail-fast`, `job-index`,
5875 `job-total`, `max-parallel` (the job count when unlimited).
5976 - [ ] `fail-fast` and `max-parallel` accept strings (`"false"`, `"2"`) after
6077 evaluation; `max-parallel` must be a whole number ≥ 1.
6178 - [ ] The 1 000-commit and 300-file limits themselves are the caller's.
79+
80+## New in the second pass
81+
82+- `Job.EvaluateRunsOn`, `Job.EvaluateIf`, `Step.EvaluateIf` and
83+ `Workflow.JobLevels` are conveniences for the planner and are not in the
84+ reference. `EvaluateRunsOn` applies the reference's runs-on checks to the
85+ evaluated value (labels must be strings; group names as in the converter).
86+ `JobLevels` is a layering of `needs` by depth, file order inside a level.
87+- The reference versions and the regeneration of its answers are in
88+ `../template/PORTING.md`.
@@ -18,6 +18,20 @@ code.
18 accepts them; GitHub refuses the file. `Parse` reports18 accepts them; GitHub refuses the file. `Parse` reports
19 `Event 'push' cannot use both 'branches' and 'branches-ignore'. Use only one19 `Event 'push' cannot use both 'branches' and 'branches-ignore'. Use only one
20 of them.` (our wording, to replace with GitHub's). Applied to every event.20 of them.` (our wording, to replace with GitHub's). Applied to every event.
21+- **A filter list made only of `!` patterns** (`branches: ['!main']`) is an
22+ error (`Event 'push' filter 'branches' has only patterns that start with
23+ '!'...`), and so is a pattern that does not compile. Both are ours; GitHub's
24+ texts are unknown. Applied to every event and every filter.
25+- **Matrix exclude** with a key that is not a vector of the matrix is
26+ `Matrix exclude key '<k>' does not match any key within the matrix`
27+ (GitHub's text as remembered, not recorded).
28+- **Limits that GitHub does not state**: the cross product of the vectors may
29+ not exceed `MaxMatrixProduct` (65 536) before exclusions, include and exclude
30+ have at most `MaxMatrixEntries` (256) entries each, and the context is
31+ checked every 1 024 combinations. They keep a hostile matrix from running
32+ for long; a legitimate one (at most 256 jobs) is far below them. The
33+ product limit also refuses a matrix GitHub would accept by excluding most
34+ of a huge product.
21 - **Reusable workflows** are parsed (`uses`, `with`, `secrets`, `inherit`) but35 - **Reusable workflows** are parsed (`uses`, `with`, `secrets`, `inherit`) but
22 not followed, and the called workflow's inputs and secrets are not checked.36 not followed, and the called workflow's inputs and secrets are not checked.
23 - Unknown event types are reported by the schema (`Unexpected value 'x'`).37 - Unknown event types are reported by the schema (`Unexpected value 'x'`).
@@ -50,12 +64,25 @@ code.
50 combinations after include, key case-insensitivity, value comparison64 combinations after include, key case-insensitivity, value comparison
51 (same kind; objects by containment), the error when all combinations are65 (same kind; objects by containment), the error when all combinations are
52 excluded (GitHub may fail the run differently), empty vector error.66 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.
53 - [ ] **Job display names** (`MatrixCombo.DisplayName`, `Job.DisplayName`):69 - [ ] **Job display names** (`MatrixCombo.DisplayName`, `Job.DisplayName`):
54 `name (v1, v2)` listing every scalar value of the combination in key70 `name (v1, v2)` listing every scalar value of the combination in key
55- order, then keys added by include; a name that refers to `matrix.` is71+ order, then keys added by include; a name that reads the `matrix`
56- used as is. Booleans/numbers/null are shown with `CoerceString`.72+ 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`.
57 - [ ] **`strategy` context** (`Strategy.Context`): `fail-fast`, `job-index`,74 - [ ] **`strategy` context** (`Strategy.Context`): `fail-fast`, `job-index`,
58 `job-total`, `max-parallel` (the job count when unlimited).75 `job-total`, `max-parallel` (the job count when unlimited).
59 - [ ] `fail-fast` and `max-parallel` accept strings (`"false"`, `"2"`) after76 - [ ] `fail-fast` and `max-parallel` accept strings (`"false"`, `"2"`) after
60 evaluation; `max-parallel` must be a whole number ≥ 1.77 evaluation; `max-parallel` must be a whole number ≥ 1.
61 - [ ] The 1 000-commit and 300-file limits themselves are the caller's.78 - [ ] The 1 000-commit and 300-file limits themselves are the caller's.
79+
80+## New in the second pass
81+
82+- `Job.EvaluateRunsOn`, `Job.EvaluateIf`, `Step.EvaluateIf` and
83+ `Workflow.JobLevels` are conveniences for the planner and are not in the
84+ reference. `EvaluateRunsOn` applies the reference's runs-on checks to the
85+ evaluated value (labels must be strings; group names as in the converter).
86+ `JobLevels` is a layering of `needs` by depth, file order inside a level.
87+- The reference versions and the regeneration of its answers are in
88+ `../template/PORTING.md`.
M workflow/gen/convert.mjs +6 -5
@@ -4,17 +4,18 @@
44 // the errors and the model summary that @actions/workflow-parser 0.3.61 (MIT,
55 // GitHub) produces. convert_test.go replays the files and compares.
66 //
7-// node gen/convert.mjs /path/to/@actions/workflow-parser/dist > testdata/convert.json
7+// gen/regen.sh
88 //
9-// The package imports "yaml" and "@actions/expressions"; the JSON import of the
10-// schema needs `with { type: "json" }` in dist/workflows/workflow-schema.js on
11-// recent Node versions.
9+// which installs the versions pinned in gen/package-lock.json and runs this
10+// script with the loader of gen/register.mjs (the package imports its JSON
11+// schema without the import attribute recent Node versions require). A dist
12+// path may be given as the first argument.
1213
1314 import { pathToFileURL } from "node:url";
1415 import path from "node:path";
1516 import fs from "node:fs";
1617
17-const dist = path.resolve(process.argv[2]);
18+const dist = path.resolve(process.argv[2] ?? new URL("../../gen/node_modules/@actions/workflow-parser/dist", import.meta.url).pathname);
1819 const imp = p => import(pathToFileURL(path.join(dist, p)).href);
1920 const { parseWorkflow } = await imp("workflows/workflow-parser.js");
2021 const { NoOperationTraceWriter } = await imp("templates/trace-writer.js");
@@ -4,17 +4,18 @@
4 // the errors and the model summary that @actions/workflow-parser 0.3.61 (MIT,4 // the errors and the model summary that @actions/workflow-parser 0.3.61 (MIT,
5 // GitHub) produces. convert_test.go replays the files and compares.5 // GitHub) produces. convert_test.go replays the files and compares.
6 //6 //
7-// node gen/convert.mjs /path/to/@actions/workflow-parser/dist > testdata/convert.json7+// gen/regen.sh
8 //8 //
9-// The package imports "yaml" and "@actions/expressions"; the JSON import of the9+// which installs the versions pinned in gen/package-lock.json and runs this
10-// schema needs `with { type: "json" }` in dist/workflows/workflow-schema.js on10+// script with the loader of gen/register.mjs (the package imports its JSON
11-// recent Node versions.11+// schema without the import attribute recent Node versions require). A dist
12+// path may be given as the first argument.
12 13
13 import { pathToFileURL } from "node:url";14 import { pathToFileURL } from "node:url";
14 import path from "node:path";15 import path from "node:path";
15 import fs from "node:fs";16 import fs from "node:fs";
16 17
17-const dist = path.resolve(process.argv[2]);18+const dist = path.resolve(process.argv[2] ?? new URL("../../gen/node_modules/@actions/workflow-parser/dist", import.meta.url).pathname);
18 const imp = p => import(pathToFileURL(path.join(dist, p)).href);19 const imp = p => import(pathToFileURL(path.join(dist, p)).href);
19 const { parseWorkflow } = await imp("workflows/workflow-parser.js");20 const { parseWorkflow } = await imp("workflows/workflow-parser.js");
20 const { NoOperationTraceWriter } = await imp("templates/trace-writer.js");21 const { NoOperationTraceWriter } = await imp("templates/trace-writer.js");

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