• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright 2023 The Pigweed Authors
2#
3# Licensed under the Apache License, Version 2.0 (the "License"); you may not
4# use this file except in compliance with the License. You may obtain a copy of
5# the License at
6#
7#     https://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12# License for the specific language governing permissions and limitations under
13# the License.
14"""pw_build.project_builder_presubmit_runner"""
15
16from __future__ import annotations
17
18import argparse
19import fnmatch
20import logging
21from pathlib import Path
22
23
24import pw_cli.log
25from pw_cli.arguments import (
26    print_completions_for_option,
27    add_tab_complete_arguments,
28)
29from pw_presubmit.presubmit import (
30    Program,
31    Programs,
32    Presubmit,
33    PresubmitContext,
34    Check,
35    fetch_file_lists,
36)
37import pw_presubmit.pigweed_presubmit
38from pw_presubmit.build import GnGenNinja, gn_args
39from pw_presubmit.presubmit_context import get_check_traces, PresubmitCheckTrace
40from pw_presubmit.tools import file_summary
41
42# pw_watch is not required by pw_build, this is an optional feature.
43try:
44    from pw_watch.argparser import (  # type: ignore
45        add_parser_arguments as add_watch_arguments,
46    )
47    from pw_watch.watch import run_watch, watch_setup  # type: ignore
48    from pw_watch.watch_app import WatchAppPrefs  # type: ignore
49
50    PW_WATCH_AVAILABLE = True
51except ImportError:
52    PW_WATCH_AVAILABLE = False
53
54from pw_build.project_builder import (
55    ProjectBuilder,
56    run_builds,
57    ASCII_CHARSET,
58    EMOJI_CHARSET,
59)
60from pw_build.build_recipe import (
61    BuildCommand,
62    BuildRecipe,
63    UnknownBuildSystem,
64    create_build_recipes,
65    should_gn_gen,
66)
67from pw_build.project_builder_argparse import add_project_builder_arguments
68from pw_build.project_builder_prefs import ProjectBuilderPrefs
69
70
71_COLOR = pw_cli.color.colors()
72_LOG = logging.getLogger('pw_build')
73
74
75class PresubmitTraceAnnotationError(Exception):
76    """Exception for malformed PresubmitCheckTrace annotations."""
77
78
79def _pw_package_install_command(package_name: str) -> BuildCommand:
80    return BuildCommand(
81        command=[
82            'pw',
83            '--no-banner',
84            'package',
85            'install',
86            package_name,
87        ],
88    )
89
90
91def _pw_package_install_to_build_command(
92    trace: PresubmitCheckTrace,
93) -> BuildCommand:
94    """Returns a BuildCommand from a PresubmitCheckTrace."""
95    package_name = trace.call_annotation.get('pw_package_install', None)
96    if package_name is None:
97        raise PresubmitTraceAnnotationError(
98            'Missing "pw_package_install" value.'
99        )
100
101    return _pw_package_install_command(package_name)
102
103
104def _bazel_command_args_to_build_commands(
105    trace: PresubmitCheckTrace,
106) -> list[BuildCommand]:
107    """Returns a list of BuildCommands based on a bazel PresubmitCheckTrace."""
108    build_steps: list[BuildCommand] = []
109
110    if not 'bazel' in trace.args:
111        return build_steps
112
113    bazel_command = list(arg for arg in trace.args if not arg.startswith('--'))
114    bazel_options = list(
115        arg for arg in trace.args if arg.startswith('--') and arg != '--'
116    )
117    # Check for `bazel build` or `bazel test`
118    if not (
119        bazel_command[0].endswith('bazel')
120        and bazel_command[1] in ['build', 'test']
121    ):
122        raise UnknownBuildSystem(
123            f'Unable to parse bazel command:\n  {trace.args}'
124        )
125
126    bazel_subcommand = bazel_command[1]
127    bazel_targets = bazel_command[2:]
128    if bazel_subcommand == 'build':
129        build_steps.append(
130            BuildCommand(
131                build_system_command='bazel',
132                build_system_extra_args=['build'] + bazel_options,
133                targets=bazel_targets,
134            )
135        )
136    if bazel_subcommand == 'test':
137        build_steps.append(
138            BuildCommand(
139                build_system_command='bazel',
140                build_system_extra_args=['test'] + bazel_options,
141                targets=bazel_targets,
142            )
143        )
144    return build_steps
145
146
147def _presubmit_trace_to_build_commands(
148    ctx: PresubmitContext,
149    presubmit_step: Check,
150) -> list[BuildCommand]:
151    """Convert a presubmit step to a list of BuildCommands.
152
153    Specifically, this handles the following types of PresubmitCheckTraces:
154
155    - pw package installs
156    - gn gen followed by ninja
157    - bazel commands
158
159    If none of the specific scenarios listed above are found the command args
160    are passed along to BuildCommand as is.
161
162    Returns:
163      List of BuildCommands representing each command found in the
164      presubmit_step traces.
165    """
166    build_steps: list[BuildCommand] = []
167
168    presubmit_step(ctx)
169
170    step_traces = get_check_traces(ctx)
171
172    for trace in step_traces:
173        trace_args = list(trace.args)
174        # Check for ninja -t graph command and skip it
175        if trace_args[0].endswith('ninja'):
176            try:
177                dash_t_index = trace_args.index('-t')
178                graph_index = trace_args.index('graph')
179                if graph_index == dash_t_index + 1:
180                    # This trace has -t graph, skip it.
181                    continue
182            except ValueError:
183                # '-t graph' was not found
184                pass
185
186        if 'pw_package_install' in trace.call_annotation:
187            build_steps.append(_pw_package_install_to_build_command(trace))
188            continue
189
190        if 'bazel' in trace.args:
191            build_steps.extend(_bazel_command_args_to_build_commands(trace))
192            continue
193
194        # Check for gn gen or pw-wrap-ninja
195        transformed_args = []
196        pw_wrap_ninja_found = False
197        gn_found = False
198        gn_gen_found = False
199
200        for arg in trace.args:
201            # Check for a 'gn gen' command
202            if arg == 'gn':
203                gn_found = True
204            if arg == 'gen' and gn_found:
205                gn_gen_found = True
206
207            # Check for pw-wrap-ninja, pw build doesn't use this.
208            if arg == 'pw-wrap-ninja':
209                # Use ninja instead
210                transformed_args.append('ninja')
211                pw_wrap_ninja_found = True
212                continue
213            # Remove --log-actions if pw-wrap-ninja was found. This is a
214            # non-standard ninja arg.
215            if pw_wrap_ninja_found and arg == '--log-actions':
216                continue
217            transformed_args.append(str(arg))
218
219        if gn_gen_found:
220            # Run the command with run_if=should_gn_gen
221            build_steps.append(
222                BuildCommand(run_if=should_gn_gen, command=transformed_args)
223            )
224        else:
225            # Run the command as is.
226            build_steps.append(BuildCommand(command=transformed_args))
227
228    return build_steps
229
230
231def presubmit_build_recipe(  # pylint: disable=too-many-locals
232    repo_root: Path,
233    presubmit_out_dir: Path,
234    package_root: Path,
235    presubmit_step: Check,
236    all_files: list[Path],
237    modified_files: list[Path],
238) -> BuildRecipe | None:
239    """Construct a BuildRecipe from a pw_presubmit step."""
240    out_dir = presubmit_out_dir / presubmit_step.name
241
242    ctx = PresubmitContext(
243        root=repo_root,
244        repos=(repo_root,),
245        output_dir=out_dir,
246        failure_summary_log=out_dir / 'failure-summary.log',
247        paths=tuple(modified_files),
248        all_paths=tuple(all_files),
249        package_root=package_root,
250        luci=None,
251        override_gn_args={},
252        num_jobs=None,
253        continue_after_build_error=True,
254        _failed=False,
255        format_options=pw_presubmit.presubmit.FormatOptions.load(),
256        dry_run=True,
257    )
258
259    presubmit_instance = Presubmit(
260        root=repo_root,
261        repos=(repo_root,),
262        output_directory=out_dir,
263        paths=modified_files,
264        all_paths=all_files,
265        package_root=package_root,
266        override_gn_args={},
267        continue_after_build_error=True,
268        rng_seed=1,
269        full=False,
270    )
271
272    program = Program('', [presubmit_step])
273    checks = list(presubmit_instance.apply_filters(program))
274    if not checks:
275        _LOG.warning('')
276        _LOG.warning(
277            'Step "%s" is not required for the current set of modified files.',
278            presubmit_step.name,
279        )
280        _LOG.warning('')
281        return None
282
283    try:
284        ctx.paths = tuple(checks[0].paths)
285    except IndexError:
286        raise PresubmitTraceAnnotationError(
287            'Missing pw_presubmit.presubmit.Check for presubmit step:\n'
288            + repr(presubmit_step)
289        )
290
291    if isinstance(presubmit_step, GnGenNinja):
292        # GnGenNinja is directly translatable to a BuildRecipe.
293        selected_gn_args = {
294            name: value(ctx) if callable(value) else value
295            for name, value in presubmit_step.gn_args.items()
296        }
297
298        return BuildRecipe(
299            build_dir=out_dir,
300            title=presubmit_step.name,
301            steps=[
302                _pw_package_install_command(name)
303                for name in presubmit_step._packages  # pylint: disable=protected-access
304            ]
305            + [
306                BuildCommand(
307                    run_if=should_gn_gen,
308                    command=[
309                        'gn',
310                        'gen',
311                        str(out_dir),
312                        gn_args(**selected_gn_args),
313                    ],
314                ),
315                BuildCommand(
316                    build_system_command='ninja',
317                    targets=presubmit_step.ninja_targets,
318                ),
319            ],
320        )
321
322    # Unknown type of presubmit, use dry-run to capture subprocess traces.
323    build_steps = _presubmit_trace_to_build_commands(ctx, presubmit_step)
324
325    out_dir.mkdir(parents=True, exist_ok=True)
326
327    return BuildRecipe(
328        build_dir=out_dir,
329        title=presubmit_step.name,
330        steps=build_steps,
331    )
332
333
334def get_parser(
335    presubmit_programs: Programs | None = None,
336    build_recipes: list[BuildRecipe] | None = None,
337) -> argparse.ArgumentParser:
338    """Setup argparse for pw_build.project_builder and optionally pw_watch."""
339    parser = argparse.ArgumentParser(
340        prog='pw build',
341        description=__doc__,
342        formatter_class=argparse.RawDescriptionHelpFormatter,
343    )
344
345    if PW_WATCH_AVAILABLE:
346        parser = add_watch_arguments(parser)
347    else:
348        parser = add_project_builder_arguments(parser)
349
350    if build_recipes is not None:
351
352        def build_recipe_argparse_type(arg: str) -> list[BuildRecipe]:
353            """Return a list of matching presubmit steps."""
354            assert build_recipes
355            all_recipe_names = list(
356                recipe.display_name for recipe in build_recipes
357            )
358            filtered_names = fnmatch.filter(all_recipe_names, arg)
359
360            if not filtered_names:
361                recipe_name_str = '\n'.join(sorted(all_recipe_names))
362                raise argparse.ArgumentTypeError(
363                    f'"{arg}" does not match the name of a recipe.\n\n'
364                    f'Valid Recipes:\n{recipe_name_str}'
365                )
366
367            return list(
368                recipe
369                for recipe in build_recipes
370                if recipe.display_name in filtered_names
371            )
372
373        parser.add_argument(
374            '-r',
375            '--recipe',
376            action='extend',
377            default=[],
378            help=(
379                'Run a build recipe. Include an asterix to match more than one '
380                "name. For example: --recipe 'gn_*'"
381            ),
382            type=build_recipe_argparse_type,
383        )
384
385    if presubmit_programs is not None:
386        # Add presubmit step arguments.
387        all_steps = presubmit_programs.all_steps()
388
389        def presubmit_step_argparse_type(arg: str) -> list[Check]:
390            """Return a list of matching presubmit steps."""
391            filtered_step_names = fnmatch.filter(all_steps.keys(), arg)
392
393            if not filtered_step_names:
394                all_step_names = '\n'.join(sorted(all_steps.keys()))
395                raise argparse.ArgumentTypeError(
396                    f'"{arg}" does not match the name of a presubmit step.\n\n'
397                    f'Valid Steps:\n{all_step_names}'
398                )
399
400            return list(all_steps[name] for name in filtered_step_names)
401
402        parser.add_argument(
403            '-s',
404            '--step',
405            action='extend',
406            default=[],
407            help=(
408                'Run presubmit step. Include an asterix to match more than one '
409                "step name. For example: --step '*_format'"
410            ),
411            type=presubmit_step_argparse_type,
412        )
413
414    if build_recipes or presubmit_programs:
415        parser.add_argument(
416            '-l',
417            '--list',
418            action='store_true',
419            default=False,
420            help=('List all known build recipes and presubmit steps.'),
421        )
422
423    if build_recipes:
424        parser.add_argument(
425            '--all',
426            action='store_true',
427            default=False,
428            help=('Run all known build recipes.'),
429        )
430
431    parser.add_argument(
432        '--progress-bars',
433        action=argparse.BooleanOptionalAction,
434        default=True,
435        help='Show progress bars in the terminal.',
436    )
437
438    parser.add_argument(
439        '--log-build-steps',
440        action=argparse.BooleanOptionalAction,
441        help='Show ninja build step log lines in output.',
442    )
443
444    if PW_WATCH_AVAILABLE:
445        parser.add_argument(
446            '-w',
447            '--watch',
448            action='store_true',
449            help='Use pw_watch to monitor changes.',
450            default=False,
451        )
452
453    parser.add_argument(
454        '-b',
455        '--base',
456        help=(
457            'Git revision to diff for changed files. This is used for '
458            'presubmit steps.'
459        ),
460    )
461
462    parser = add_tab_complete_arguments(parser)
463
464    parser.add_argument(
465        '--tab-complete-recipe',
466        nargs='?',
467        help='Print tab completions for the supplied recipe name.',
468    )
469
470    parser.add_argument(
471        '--tab-complete-presubmit-step',
472        nargs='?',
473        help='Print tab completions for the supplied presubmit name.',
474    )
475
476    return parser
477
478
479def _get_prefs(
480    args: argparse.Namespace,
481) -> ProjectBuilderPrefs | WatchAppPrefs:
482    """Load either WatchAppPrefs or ProjectBuilderPrefs.
483
484    Applies the command line args to the correct prefs class.
485
486    Returns:
487      A WatchAppPrefs instance if pw_watch is importable, ProjectBuilderPrefs
488      otherwise.
489    """
490    prefs: ProjectBuilderPrefs | WatchAppPrefs
491    if PW_WATCH_AVAILABLE:
492        prefs = WatchAppPrefs(load_argparse_arguments=add_watch_arguments)
493        prefs.apply_command_line_args(args)
494    else:
495        prefs = ProjectBuilderPrefs(
496            load_argparse_arguments=add_project_builder_arguments
497        )
498        prefs.apply_command_line_args(args)
499    return prefs
500
501
502def load_presubmit_build_recipes(
503    presubmit_programs: Programs,
504    presubmit_steps: list[Check],
505    repo_root: Path,
506    presubmit_out_dir: Path,
507    package_root: Path,
508    all_files: list[Path],
509    modified_files: list[Path],
510    default_presubmit_step_names: list[str] | None = None,
511) -> list[BuildRecipe]:
512    """Convert selected presubmit steps into a list of BuildRecipes."""
513    # Use the default presubmit if no other steps or command line out
514    # directories are provided.
515    if len(presubmit_steps) == 0 and default_presubmit_step_names:
516        default_steps = list(
517            check
518            for name, check in presubmit_programs.all_steps().items()
519            if name in default_presubmit_step_names
520        )
521        presubmit_steps = default_steps
522
523    presubmit_recipes: list[BuildRecipe] = []
524
525    for step in presubmit_steps:
526        build_recipe = presubmit_build_recipe(
527            repo_root,
528            presubmit_out_dir,
529            package_root,
530            step,
531            all_files,
532            modified_files,
533        )
534        if build_recipe:
535            presubmit_recipes.append(build_recipe)
536
537    return presubmit_recipes
538
539
540def _tab_complete_recipe(
541    build_recipes: list[BuildRecipe],
542    text: str = '',
543) -> None:
544    for name in sorted(recipe.display_name for recipe in build_recipes):
545        if name.startswith(text):
546            print(name)
547
548
549def _tab_complete_presubmit_step(
550    presubmit_programs: Programs,
551    text: str = '',
552) -> None:
553    for name in sorted(presubmit_programs.all_steps().keys()):
554        if name.startswith(text):
555            print(name)
556
557
558def _list_steps_and_recipes(
559    presubmit_programs: Programs | None = None,
560    build_recipes: list[BuildRecipe] | None = None,
561) -> None:
562    if presubmit_programs:
563        _LOG.info('Presubmit steps:')
564        print()
565        for name in sorted(presubmit_programs.all_steps().keys()):
566            print(name)
567        print()
568    if build_recipes:
569        _LOG.info('Build recipes:')
570        print()
571        for name in sorted(recipe.display_name for recipe in build_recipes):
572            print(name)
573        print()
574
575
576def _print_usage_help(
577    presubmit_programs: Programs | None = None,
578    build_recipes: list[BuildRecipe] | None = None,
579) -> None:
580    """Print usage examples with known presubmits and build recipes."""
581
582    def print_pw_build(
583        option: str, arg: str | None = None, end: str = '\n'
584    ) -> None:
585        print(
586            ' '.join(
587                [
588                    'pw build',
589                    _COLOR.cyan(option),
590                    _COLOR.yellow(arg) if arg else '',
591                ]
592            ),
593            end=end,
594        )
595
596    if presubmit_programs:
597        print(_COLOR.green('All presubmit steps:'))
598        for name in sorted(presubmit_programs.all_steps().keys()):
599            print_pw_build('--step', name)
600    if build_recipes:
601        if presubmit_programs:
602            # Add a blank line separator
603            print()
604        print(_COLOR.green('All build recipes:'))
605        for name in sorted(recipe.display_name for recipe in build_recipes):
606            print_pw_build('--recipe', name)
607
608        print()
609        print(
610            _COLOR.green(
611                'Recipe and step names may use wildcards and be repeated:'
612            )
613        )
614        print_pw_build('--recipe', '"default_*"', end=' ')
615        print(
616            _COLOR.cyan('--step'),
617            _COLOR.yellow('step1'),
618            _COLOR.cyan('--step'),
619            _COLOR.yellow('step2'),
620        )
621        print()
622        print(_COLOR.green('Run all build recipes:'))
623        print_pw_build('--all')
624        print()
625        print(_COLOR.green('For more help please run:'))
626        print_pw_build('--help')
627
628
629def main(
630    presubmit_programs: Programs | None = None,
631    default_presubmit_step_names: list[str] | None = None,
632    build_recipes: list[BuildRecipe] | None = None,
633    default_build_recipe_names: list[str] | None = None,
634    repo_root: Path | None = None,
635    presubmit_out_dir: Path | None = None,
636    package_root: Path | None = None,
637    default_root_logfile: Path = Path('out/build.txt'),
638    force_pw_watch: bool = False,
639) -> int:
640    """Build upstream Pigweed presubmit steps."""
641    # pylint: disable=too-many-locals,too-many-branches
642    parser = get_parser(presubmit_programs, build_recipes)
643    args = parser.parse_args()
644
645    if args.tab_complete_option is not None:
646        print_completions_for_option(
647            parser,
648            text=args.tab_complete_option,
649            tab_completion_format=args.tab_complete_format,
650        )
651        return 0
652
653    log_level = logging.DEBUG if args.debug_logging else logging.INFO
654
655    pw_cli.log.install(
656        level=log_level,
657        use_color=args.colors,
658        # Hide the date from the timestamp
659        time_format='%H:%M:%S',
660    )
661
662    pw_env = pw_cli.env.pigweed_environment()
663    if pw_env.PW_EMOJI:
664        charset = EMOJI_CHARSET
665    else:
666        charset = ASCII_CHARSET
667
668    if args.tab_complete_recipe is not None:
669        if build_recipes:
670            _tab_complete_recipe(build_recipes, text=args.tab_complete_recipe)
671        # Must exit if there are no build_recipes.
672        return 0
673
674    if args.tab_complete_presubmit_step is not None:
675        if presubmit_programs:
676            _tab_complete_presubmit_step(
677                presubmit_programs, text=args.tab_complete_presubmit_step
678            )
679        # Must exit if there are no presubmit_programs.
680        return 0
681
682    # List valid steps + recipes.
683    if hasattr(args, 'list') and args.list:
684        _list_steps_and_recipes(presubmit_programs, build_recipes)
685        return 0
686
687    command_line_dash_c_recipes: list[BuildRecipe] = []
688    # If -C out directories are provided add them to the recipes list.
689    if args.build_directories:
690        prefs = _get_prefs(args)
691        command_line_dash_c_recipes = create_build_recipes(prefs)
692
693    if repo_root is None:
694        repo_root = pw_env.PW_PROJECT_ROOT
695    if presubmit_out_dir is None:
696        presubmit_out_dir = repo_root / 'out/presubmit'
697    if package_root is None:
698        package_root = pw_env.PW_PACKAGE_ROOT
699
700    all_files: list[Path]
701    modified_files: list[Path]
702
703    all_files, modified_files = fetch_file_lists(
704        root=repo_root,
705        repo=repo_root,
706        pathspecs=[],
707        base=args.base,
708    )
709
710    # Log modified file summary just like pw_presubmit if using --base.
711    if args.base:
712        _LOG.info(
713            'Running steps that apply to modified files since "%s":', args.base
714        )
715        _LOG.info('')
716        for line in file_summary(
717            mf.relative_to(repo_root) for mf in modified_files
718        ):
719            _LOG.info(line)
720        _LOG.info('')
721
722    selected_build_recipes: list[BuildRecipe] = []
723    if build_recipes:
724        if hasattr(args, 'recipe'):
725            selected_build_recipes = args.recipe
726        if not selected_build_recipes and default_build_recipe_names:
727            selected_build_recipes = [
728                recipe
729                for recipe in build_recipes
730                if recipe.display_name in default_build_recipe_names
731            ]
732
733    selected_presubmit_recipes: list[BuildRecipe] = []
734    if presubmit_programs and hasattr(args, 'step'):
735        selected_presubmit_recipes = load_presubmit_build_recipes(
736            presubmit_programs,
737            args.step,
738            repo_root,
739            presubmit_out_dir,
740            package_root,
741            all_files,
742            modified_files,
743            default_presubmit_step_names=default_presubmit_step_names,
744        )
745
746    # If no builds specifed on the command line print a useful help message:
747    if (
748        not selected_build_recipes
749        and not command_line_dash_c_recipes
750        and not selected_presubmit_recipes
751        and not args.all
752    ):
753        _print_usage_help(presubmit_programs, build_recipes)
754        return 1
755
756    if build_recipes and args.all:
757        selected_build_recipes = build_recipes
758
759    # Run these builds in order:
760    recipes_to_build = (
761        # -C dirs
762        command_line_dash_c_recipes
763        # --step 'name'
764        + selected_presubmit_recipes
765        # --recipe 'name'
766        + selected_build_recipes
767    )
768
769    # Always set separate build file logging.
770    if not args.logfile:
771        args.logfile = default_root_logfile
772    if not args.separate_logfiles:
773        args.separate_logfiles = True
774
775    workers = 1
776    if args.parallel:
777        # If parallel is requested and parallel_workers is set to 0 run all
778        # recipes in parallel. That is, use the number of recipes as the worker
779        # count.
780        if args.parallel_workers == 0:
781            workers = len(recipes_to_build)
782        else:
783            workers = args.parallel_workers
784
785    project_builder = ProjectBuilder(
786        build_recipes=recipes_to_build,
787        jobs=args.jobs,
788        banners=args.banners,
789        keep_going=args.keep_going,
790        colors=args.colors,
791        charset=charset,
792        separate_build_file_logging=args.separate_logfiles,
793        # If running builds in serial, send all sub build logs to the root log
794        # window (or terminal).
795        send_recipe_logs_to_root=(workers == 1),
796        root_logfile=args.logfile,
797        root_logger=_LOG,
798        log_level=log_level,
799        allow_progress_bars=args.progress_bars,
800        log_build_steps=args.log_build_steps,
801    )
802
803    if project_builder.should_use_progress_bars():
804        project_builder.use_stdout_proxy()
805
806    if PW_WATCH_AVAILABLE and (
807        force_pw_watch or (args.watch or args.fullscreen)
808    ):
809        event_handler, exclude_list = watch_setup(
810            project_builder,
811            parallel=args.parallel,
812            parallel_workers=workers,
813            fullscreen=args.fullscreen,
814            logfile=args.logfile,
815            separate_logfiles=args.separate_logfiles,
816        )
817
818        run_watch(
819            event_handler,
820            exclude_list,
821            fullscreen=args.fullscreen,
822        )
823        return 0
824
825    # One off build
826    return run_builds(project_builder, workers)
827