Simon Jakobi pushed to branch wip/sjakobi/T16720 at Glasgow Haskell Compiler / GHC Commits: 79bf1dbb by Simon Jakobi at 2026-07-27T14:57:17+02:00 testsuite: Colorize the test summary, also in CI The summary headings were plain, and SUMMARY was colored unconditionally, so the escapes also ended up in the file written by --summary-file. Color is now decided per output sink via term_color.colored_if; see the comments in term_color. CI logs are not a tty, but GitLab's log viewer renders ANSI colors, so add --force-colors and pass it in .gitlab/ci.sh. Assisted-by: Claude Opus 5 - - - - - c0f7ea03 by Simon Jakobi at 2026-07-27T14:57:21+02:00 testsuite: Repeat unexpected failure output in the summary Finding out why a test failed meant scrolling back through a possibly very long log to the point where the test ran. The summary now repeats the captured output of unexpected failures, before the statistics, so the interesting part is at the end of the log (#16720). Output is bounded per stream and skipped altogether beyond MAX_SUMMARY_OUTPUT_TESTS failures, pointing at the JUnit file instead; tests failing identically in several ways share one block. Test results now report a source-relative directory, which is stable regardless of where the run was started from. Assisted-by: Claude Opus 5 - - - - - 4809cc13 by Simon Jakobi at 2026-07-27T15:39:59+02:00 testsuite: Show the output diff in the failure summary Output mismatches — the most common failure kind — carried their diff but no stdout/stderr, so they showed up in the summary as a bare header with nothing under it (#16720). Report the diff too. The diff and the stream it was computed from overlap, so the mismatching stream is now dropped in favour of the diff; see Note [Redundant output in test results]. Assisted-by: Claude Opus 5 - - - - - e04b8a1d by Simon Jakobi at 2026-07-27T15:40:08+02:00 TEMP testsuite: Deliberately failing tests — DO NOT MERGE Exercises the failure-output summary (#16720) in CI: an output mismatch (in two ways, to check grouping), an stderr mismatch, a compile-time stderr mismatch, a bad exit code, and a mismatch long enough to hit the line cap. Assisted-by: Claude Opus 5 - - - - - ca92cf20 by Simon Jakobi at 2026-07-27T15:51:02+02:00 testsuite: Cap the output summary by blocks, not results The threshold for omitting repeated failure output counted (test, way) results, so a single test failing in many ways could suppress the output of the whole summary even though it collapses into one block. Group the results first, then compare the number of blocks against the threshold. Assisted-by: Claude Opus 5 - - - - - f4b52df0 by Simon Jakobi at 2026-07-27T15:55:24+02:00 testsuite: Sort the two failure summaries consistently The output summary and the test-infos listing sorted their results by different keys, so the same failures appeared in different orders. Sort both through one sorted_results helper, by (testname, directory, way): directory before way matches the grouping key of the output summary, so a test's ways stay adjacent. Assisted-by: Claude Opus 5 - - - - - 399a966a by Simon Jakobi at 2026-07-27T15:58:59+02:00 testsuite: Fall back to the absolute source path in result headers When os.path.relpath fails because there is no relative path between the test's source directory and the source root (e.g. different Windows drives), _result_directory returned '', which made the failure summary header render a bare '/' and dropped the location from the failure list entirely. Return the absolute path instead. Assisted-by: Claude Opus 5 - - - - - 3647ba9b by Simon Jakobi at 2026-07-27T16:00:52+02:00 testsuite: Fix stale comment on term_color.enable_color --force-colors also enables color, so the comment's claim that the flag comes from the tty check is no longer accurate. Assisted-by: Claude Opus 5 - - - - - 15 changed files: - .gitlab/ci.sh - testsuite/driver/runtests.py - testsuite/driver/term_color.py - testsuite/driver/testlib.py - + testsuite/tests/T16720-broken/BrokenBigStdout.hs - + testsuite/tests/T16720-broken/BrokenBigStdout.stdout - + testsuite/tests/T16720-broken/BrokenCompile.hs - + testsuite/tests/T16720-broken/BrokenCompile.stderr - + testsuite/tests/T16720-broken/BrokenExit.hs - + testsuite/tests/T16720-broken/BrokenStderr.hs - + testsuite/tests/T16720-broken/BrokenStderr.stderr - + testsuite/tests/T16720-broken/BrokenStderr.stdout - + testsuite/tests/T16720-broken/BrokenStdout.hs - + testsuite/tests/T16720-broken/BrokenStdout.stdout - + testsuite/tests/T16720-broken/all.T Changes: ===================================== .gitlab/ci.sh ===================================== @@ -652,6 +652,10 @@ function test_hadrian() { check_msys2_deps _build/stage1/bin/ghc --version check_release_build + # GitLab's log viewer renders ANSI colors, but stdout here is not a tty, + # so the driver must be told to emit them. + RUNTEST_ARGS="${RUNTEST_ARGS:-} --force-colors" + # Ensure that statically-linked builds are actually static if [[ "${BUILD_FLAVOUR}" = *static* ]]; then bad_execs="" ===================================== testsuite/driver/runtests.py ===================================== @@ -94,6 +94,8 @@ parser.add_argument("--ignore-perf-failures", choices=['increases','decreases',' help="Do not fail due to out-of-tolerance perf tests") parser.add_argument("--only-report-hadrian-deps", type=Path, help="Dry run the testsuite and report all extra hadrian dependencies needed on the given file") +parser.add_argument("--force-colors", action="store_true", + help="emit ANSI colors even when stdout is not a tty (e.g. for CI logs)") args = parser.parse_args() @@ -259,7 +261,9 @@ def supports_colors(): return True config.supports_colors = supports_colors() -term_color.enable_color = config.supports_colors +# config.supports_colors deliberately stays tty-based: it also guards +# terminal-title updates, which must not end up in a CI log. +term_color.enable_color = config.supports_colors or args.force_colors # This has to come after arg parsing as the args can change the compiler get_compiler_info() @@ -587,7 +591,7 @@ else: print(Perf.allow_changes_string([(m.change, m.stat) for m in t.metrics])) print('-' * 25) - summary(t, sys.stdout, color=config.supports_colors) + summary(t, sys.stdout, color=term_color.enable_color, junit_path=args.junit) # Write perf stats if any exist or if a metrics file is specified. stats_metrics = [stat for (_, stat, __) in t.metrics] # type: List[PerfStat] ===================================== testsuite/driver/term_color.py ===================================== @@ -1,5 +1,6 @@ from enum import Enum +# Whether to emit color escapes; set in runtests.py. enable_color = True class Color(Enum): @@ -18,3 +19,7 @@ def colored(color: Color, s: str) -> str: else: return s +# For renderers that serve several sinks: `enabled` says whether *this* sink +# takes color (the summary is written both to stdout and to a plain-text file). +def colored_if(enabled: bool, color: Color, s: str) -> str: + return colored(color, s) if enabled else s ===================================== testsuite/driver/testlib.py ===================================== @@ -27,7 +27,7 @@ from testutil import strip_quotes, lndir, link_or_copy_file, passed, \ failBecause, testing_metrics, residency_testing_metrics, \ stable_perf_counters, \ PassFail, badResult, str_warn, str_removeprefix -from term_color import Color, colored +from term_color import Color, colored_if import testutil from cpu_features import have_cpu_feature import perf_notes as Perf @@ -1497,6 +1497,19 @@ def _newTestDir(name: TestName, opts: TestOptions, tempdir, dir): opts.testdir_raw = Path(os.path.join(tempdir, testdir, name + testdir_suffix)) opts.compiler_always_flags = config.compiler_always_flags +def _result_directory(opts: TestOptions) -> str: + # The test's source directory, relative to the GHC source root, so it reads + # the same regardless of which directory `make` was invoked from. + srcdir = opts.srcdir + if srcdir is None: + return '' + try: + return os.path.relpath(srcdir, config.top.parent) + except ValueError: + # No relative path exists (e.g. different Windows drives); the + # absolute path is still more useful than nothing. + return str(srcdir) + # ----------------------------------------------------------------------------- # Actually doing tests @@ -1821,7 +1834,7 @@ async def do_test(name: TestName, if opts.expect not in ['pass', 'fail', 'missing-lib']: framework_fail(name, way, 'bad expected ' + opts.expect) - directory = str_removeprefix(str_removeprefix(str(opts.testdir), './'), '.\\') + directory = _result_directory(opts) if way in opts.fragile_ways: if_verbose(1, '*** fragile test %s resulted in %s' % (full_name, 'pass' if result.passed else 'fail')) @@ -1875,7 +1888,7 @@ def framework_fail(name: Optional[TestName], way: Optional[WayName], reason: str # so we need to take care not to blow up with the wrong way # and report the actual reason for the failure. try: - directory = str_removeprefix(str_removeprefix(str(opts.testdir), './'), '.\\') + directory = _result_directory(opts) except: directory = '' full_name = '%s(%s)' % (name, way) @@ -1888,7 +1901,7 @@ def framework_fail(name: Optional[TestName], way: Optional[WayName], reason: str def framework_warn(name: TestName, way: WayName, reason: str) -> None: opts = getTestOpts() - directory = str_removeprefix(str_removeprefix(str(opts.testdir), './'), '.\\') + directory = _result_directory(opts) full_name = name + '(' + way + ')' if_verbose(1, '*** framework warning for %s %s ' % (full_name, reason)) t.framework_warnings.append(TestResult(directory, name, reason, way)) @@ -2443,19 +2456,23 @@ async def simple_run(name: TestName, way: WayName, prog: str, extra_run_opts: st dump_stdout(name) dump_stderr(name) message = format_bad_exit_code_message(exit_code) - return failBecause(message) + return failBecause(message, + stderr=read_stderr(name), + stdout=read_stdout(name)) stderr_match = CompareOutput(True) if (opts.ignore_stderr or opts.combined_output) else await stderr_ok(name, way) if not stderr_match: + # The diff already contains the mismatching stream; see Note [Redundant + # output in test results]. return failBecause('bad stderr', - stderr=read_stderr(name), + stderr=None if stderr_match.diff else read_stderr(name), stdout=read_stdout(name), diff=stderr_match.diff) stdout_match = CompareOutput(True) if opts.ignore_stdout else await stdout_ok(name, way) if not stdout_match: return failBecause('bad stdout', stderr=read_stderr(name), - stdout=read_stdout(name), + stdout=None if stdout_match.diff else read_stdout(name), diff=stdout_match.diff) check_hp = '-hT' in my_rts_flags and opts.check_hp @@ -2565,8 +2582,9 @@ async def interpreter_run(name: TestName, if not stderr_match: if _expect_pass(way): dump_stderr_for('comp', name) + # See Note [Redundant output in test results]. return failBecause('bad stderr', - stderr=read_stderr(name), + stderr=None if stderr_match.diff else read_stderr(name), stdout=read_stdout(name), diff=stderr_match.diff) stdout_match = CompareOutput(True) if opts.ignore_stdout else await stdout_ok(name, way) @@ -2575,7 +2593,7 @@ async def interpreter_run(name: TestName, dump_stderr_for('comp', name) return failBecause('bad stdout', stderr=read_stderr(name), - stdout=read_stdout(name), + stdout=None if stdout_match.diff else read_stdout(name), diff=stdout_match.diff) return passed() @@ -3569,12 +3587,50 @@ def findTFiles(roots: List[str]) -> Iterator[str]: # ----------------------------------------------------------------------------- # Output a test summary to the specified file object -def summary(t: TestRun, file: TextIO, color=False) -> None: +def summary(t: TestRun, file: TextIO, color=False, junit_path: Optional[Path]=None) -> None: file.write('\n') + + if t.unexpected_failures: + # Count output blocks rather than results: a test failing in many ways + # collapses to a single block. + groups = groupTestOutput(t.unexpected_failures) + if len(groups) <= MAX_SUMMARY_OUTPUT_TESTS: + printTestOutputSummary(file, groups, color, junit_path) + else: + where = '; see {}'.format(junit_path) if junit_path else '' + header = ('Unexpected failures (more than {}, output omitted{}):' + .format(MAX_SUMMARY_OUTPUT_TESTS, where)) + file.write(colored_if(color, Color.RED, header) + '\n') + printTestInfosSummary(file, t.unexpected_failures) + + if t.unexpected_passes: + header = 'Unexpected passes:' + file.write(colored_if(color, Color.RED, header) + '\n') + printTestInfosSummary(file, t.unexpected_passes) + + if t.unexpected_stat_failures: + header = 'Unexpected stat failures:' + file.write(colored_if(color, Color.RED, header) + '\n') + printTestInfosSummary(file, t.unexpected_stat_failures) + + if t.framework_failures: + header = 'Framework failures:' + file.write(colored_if(color, Color.RED, header) + '\n') + printTestInfosSummary(file, t.framework_failures) + + if t.framework_warnings: + header = 'Framework warnings:' + file.write(colored_if(color, Color.YELLOW, header) + '\n') + printTestInfosSummary(file, t.framework_warnings) + + if stopping(): + warning = 'WARNING: Testsuite run was terminated early' + file.write(colored_if(color, Color.YELLOW, warning) + '\n') + printUnexpectedTests(file, [t.unexpected_passes, t.unexpected_failures, - t.unexpected_stat_failures, t.framework_failures]) + t.unexpected_stat_failures, t.framework_failures], color) if len(t.unexpected_failures) > 0 or \ len(t.unexpected_stat_failures) > 0 or \ @@ -3585,7 +3641,8 @@ def summary(t: TestRun, file: TextIO, color=False) -> None: summary_color = Color.GREEN assert t.start_time is not None - file.write(colored(summary_color, 'SUMMARY') + ' for test run started at ' + summary_header = colored_if(color, summary_color, 'SUMMARY') + file.write(summary_header + ' for test run started at ' + t.start_time.strftime("%c %Z") + '\n' + str(datetime.datetime.now() - t.start_time).rjust(8) + ' spent to go through\n' @@ -3617,46 +3674,102 @@ def summary(t: TestRun, file: TextIO, color=False) -> None: + ' fragile tests\n' + '\n') - if t.unexpected_passes: - file.write('Unexpected passes:\n') - printTestInfosSummary(file, t.unexpected_passes) - - if t.unexpected_failures: - file.write('Unexpected failures:\n') - printTestInfosSummary(file, t.unexpected_failures) - - if t.unexpected_stat_failures: - file.write('Unexpected stat failures:\n') - printTestInfosSummary(file, t.unexpected_stat_failures) - - if t.framework_failures: - file.write('Framework failures:\n') - printTestInfosSummary(file, t.framework_failures) - - if t.framework_warnings: - file.write('Framework warnings:\n') - printTestInfosSummary(file, t.framework_warnings) - - if stopping(): - file.write('WARNING: Testsuite run was terminated early\n') - -def printUnexpectedTests(file: TextIO, testInfoss): +def printUnexpectedTests(file: TextIO, testInfoss, color=False): unexpected = set(result.testname for testInfos in testInfoss for result in testInfos if not result.testname.endswith('.T')) if unexpected: - file.write('Unexpected results from:\n') + header = 'Unexpected results from:' + file.write(colored_if(color, Color.RED, header) + '\n') file.write('TEST="' + ' '.join(sorted(unexpected)) + '"\n') file.write('\n') +# Per-stream cap on a failing test's output repeated in the final summary. +MAX_SUMMARY_OUTPUT_LINES = 100 + +# Above this many output blocks, skip repeating output entirely: the dump +# would drown out the summary. +MAX_SUMMARY_OUTPUT_TESTS = 20 + +""" +Note [Redundant output in test results] +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +A failing test result carries up to three pieces of output: `diff`, `stdout` +and `stderr`. For an output mismatch these overlap: the diff's `+` lines are +the very stream that mismatched, normalised. Reporting both would print the +same text twice, so the mismatching stream is dropped at the call sites in +favour of the diff, which additionally shows what was expected. The *other* +stream is kept: on a stdout mismatch, stderr is independent context. + +The drop is conditional on there being a diff at all: compare_outputs only +runs `diff` when config.verbose >= 1, so under -v0 the stream is the only +output there is. +""" + +def strip_diff_header(diff: Optional[str]) -> Optional[str]: + # Drop diff(1)'s ---/+++ lines: they name normalised files in the test + # directory and carry timestamps, which would also keep otherwise + # identical failures from being grouped. + if diff is None: + return None + lines = diff.split('\n') + if len(lines) >= 2 and lines[0].startswith('--- ') and lines[1].startswith('+++ '): + return '\n'.join(lines[2:]) + return diff + +def sorted_results(testInfos: List[TestResult]) -> List[TestResult]: + return sorted(testInfos, key=lambda r: (r.testname.lower(), r.directory, r.way)) + +# Tests that fail identically in several ways (e.g. normal and g1) share one +# output block, with the ways collected in the header. +def groupTestOutput(testInfos: List[TestResult] + ) -> List[Tuple[TestResult, str, List[WayName]]]: + groups = collections.OrderedDict() # type: ignore + for result in sorted_results(testInfos): + diff = strip_diff_header(result.diff) + key = (result.testname, result.directory, result.reason, + diff, result.stdout, result.stderr) + groups.setdefault(key, (result, diff, []))[2].append(result.way) + return list(groups.values()) + +def printTestOutputSummary(file: TextIO, + groups: List[Tuple[TestResult, str, List[WayName]]], + color: bool=False, + junit_path: Optional[Path]=None) -> None: + # Repeat failing tests' captured output in the summary, so one needn't + # hunt for it earlier in a possibly very long log; see #16720. + header = '=====> Unexpected failures output summary' + file.write(colored_if(color, Color.RED, header) + '\n\n') + + where = ', see {}'.format(junit_path) if junit_path else '' + for result, diff, ways in groups: + header = '=====> {}({}) ({}) [{}]'.format( + result.testname, ', '.join(ways), result.directory + os.sep, result.reason) + file.write(colored_if(color, Color.RED, header) + '\n') + # See Note [Redundant output in test results] for why these don't overlap. + for label, contents in [('Output diff (expected vs actual):', diff), + ('Captured stdout:', result.stdout), + ('Captured stderr:', result.stderr)]: + if contents and contents.strip(): + lines = contents.rstrip('\n').split('\n') + if len(lines) > MAX_SUMMARY_OUTPUT_LINES: + omitted = len(lines) - MAX_SUMMARY_OUTPUT_LINES + lines = lines[:MAX_SUMMARY_OUTPUT_LINES] \ + + ['... ({} more lines omitted{})'.format(omitted, where)] + s = colored_if(color, Color.CYAN, label) + '\n' \ + + ''.join(l + '\n' for l in lines) + # Test output can contain characters that file's encoding + # cannot represent; replace rather than crash (cf safe_print). + enc = getattr(file, 'encoding', None) or 'utf-8' + file.write(s.encode(enc, errors='replace').decode(enc)) + footer = '<===== end of unexpected failures output summary' + file.write(colored_if(color, Color.RED, footer) + '\n\n') + def printTestInfosSummary(file: TextIO, testInfos): - maxDirLen = max(len(tr.directory) for tr in testInfos) - for result in sorted(testInfos, key=lambda r: (r.testname.lower(), r.way, r.directory)): - directory = result.directory.ljust(maxDirLen) - file.write(' {directory} {r.testname} [{r.reason}] ({r.way})\n'.format( - r = result, - directory = directory)) + for result in sorted_results(testInfos): + path = os.path.join(result.directory, result.testname) + file.write(' {path} [{r.reason}] ({r.way})\n'.format(r=result, path=path)) file.write('\n') def modify_lines(s: str, f: Callable[[str], str]) -> str: ===================================== testsuite/tests/T16720-broken/BrokenBigStdout.hs ===================================== @@ -0,0 +1,2 @@ +main :: IO () +main = mapM_ (\i -> putStrLn ("actual line " ++ show i)) [1 :: Int ..150] ===================================== testsuite/tests/T16720-broken/BrokenBigStdout.stdout ===================================== @@ -0,0 +1,150 @@ +expected line 1 +expected line 2 +expected line 3 +expected line 4 +expected line 5 +expected line 6 +expected line 7 +expected line 8 +expected line 9 +expected line 10 +expected line 11 +expected line 12 +expected line 13 +expected line 14 +expected line 15 +expected line 16 +expected line 17 +expected line 18 +expected line 19 +expected line 20 +expected line 21 +expected line 22 +expected line 23 +expected line 24 +expected line 25 +expected line 26 +expected line 27 +expected line 28 +expected line 29 +expected line 30 +expected line 31 +expected line 32 +expected line 33 +expected line 34 +expected line 35 +expected line 36 +expected line 37 +expected line 38 +expected line 39 +expected line 40 +expected line 41 +expected line 42 +expected line 43 +expected line 44 +expected line 45 +expected line 46 +expected line 47 +expected line 48 +expected line 49 +expected line 50 +expected line 51 +expected line 52 +expected line 53 +expected line 54 +expected line 55 +expected line 56 +expected line 57 +expected line 58 +expected line 59 +expected line 60 +expected line 61 +expected line 62 +expected line 63 +expected line 64 +expected line 65 +expected line 66 +expected line 67 +expected line 68 +expected line 69 +expected line 70 +expected line 71 +expected line 72 +expected line 73 +expected line 74 +expected line 75 +expected line 76 +expected line 77 +expected line 78 +expected line 79 +expected line 80 +expected line 81 +expected line 82 +expected line 83 +expected line 84 +expected line 85 +expected line 86 +expected line 87 +expected line 88 +expected line 89 +expected line 90 +expected line 91 +expected line 92 +expected line 93 +expected line 94 +expected line 95 +expected line 96 +expected line 97 +expected line 98 +expected line 99 +expected line 100 +expected line 101 +expected line 102 +expected line 103 +expected line 104 +expected line 105 +expected line 106 +expected line 107 +expected line 108 +expected line 109 +expected line 110 +expected line 111 +expected line 112 +expected line 113 +expected line 114 +expected line 115 +expected line 116 +expected line 117 +expected line 118 +expected line 119 +expected line 120 +expected line 121 +expected line 122 +expected line 123 +expected line 124 +expected line 125 +expected line 126 +expected line 127 +expected line 128 +expected line 129 +expected line 130 +expected line 131 +expected line 132 +expected line 133 +expected line 134 +expected line 135 +expected line 136 +expected line 137 +expected line 138 +expected line 139 +expected line 140 +expected line 141 +expected line 142 +expected line 143 +expected line 144 +expected line 145 +expected line 146 +expected line 147 +expected line 148 +expected line 149 +expected line 150 ===================================== testsuite/tests/T16720-broken/BrokenCompile.hs ===================================== @@ -0,0 +1,4 @@ +module BrokenCompile where + +foo :: Int +foo = "not an Int" ===================================== testsuite/tests/T16720-broken/BrokenCompile.stderr ===================================== @@ -0,0 +1,6 @@ + +BrokenCompile.hs:4:7: error: [GHC-83865] + • Couldn't match expected type: Bool + with actual type: Int + • In the expression: "not an Int" + In an equation for 'foo': foo = "not an Int" ===================================== testsuite/tests/T16720-broken/BrokenExit.hs ===================================== @@ -0,0 +1,8 @@ +import System.Exit +import System.IO + +main :: IO () +main = do + putStrLn "some stdout before dying" + hPutStrLn stderr "some stderr before dying" + exitWith (ExitFailure 3) ===================================== testsuite/tests/T16720-broken/BrokenStderr.hs ===================================== @@ -0,0 +1,6 @@ +import System.IO + +main :: IO () +main = do + putStrLn "this stdout is fine and independent of the stderr mismatch" + hPutStrLn stderr "actual complaint" ===================================== testsuite/tests/T16720-broken/BrokenStderr.stderr ===================================== @@ -0,0 +1 @@ +expected complaint ===================================== testsuite/tests/T16720-broken/BrokenStderr.stdout ===================================== @@ -0,0 +1 @@ +this stdout is fine and independent of the stderr mismatch ===================================== testsuite/tests/T16720-broken/BrokenStdout.hs ===================================== @@ -0,0 +1,5 @@ +main :: IO () +main = do + putStrLn "line one" + putStrLn "actual line two" + putStrLn "line three" ===================================== testsuite/tests/T16720-broken/BrokenStdout.stdout ===================================== @@ -0,0 +1,3 @@ +line one +expected line two +line three ===================================== testsuite/tests/T16720-broken/all.T ===================================== @@ -0,0 +1,8 @@ +# Deliberately failing tests, to exercise the failure-output summary (#16720). +# NOT FOR MERGE — drop this directory before the MR is merged. + +test('BrokenStdout', extra_ways(['optasm']), compile_and_run, ['']) +test('BrokenStderr', normal, compile_and_run, ['']) +test('BrokenExit', normal, compile_and_run, ['']) +test('BrokenCompile', normal, compile_fail, ['']) +test('BrokenBigStdout', normal, compile_and_run, ['']) View it on GitLab: https://gitlab.haskell.org/ghc/ghc/-/compare/a38f657ea5d7d31031730d546d32b85... -- View it on GitLab: https://gitlab.haskell.org/ghc/ghc/-/compare/a38f657ea5d7d31031730d546d32b85... You're receiving this email because of your account on gitlab.haskell.org. Manage all notifications: https://gitlab.haskell.org/-/profile/notifications | Help: https://gitlab.haskell.org/help
participants (1)
-
Simon Jakobi (@sjakobi)