repository.dart 21.7 KB
Newer Older
1 2 3 4 5 6 7 8 9 10
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:convert' show jsonDecode;
import 'dart:io' as io;

import 'package:file/file.dart';
import 'package:meta/meta.dart';
import 'package:platform/platform.dart';
11
import 'package:process/process.dart';
12 13

import './git.dart';
14
import './globals.dart';
15 16 17
import './stdio.dart';
import './version.dart';

18 19 20 21 22 23 24 25
/// Allowed git remote names.
enum RemoteName {
  upstream,
  mirror,
}

class Remote {
  const Remote({
26 27
    required RemoteName name,
    required this.url,
28 29 30
  })  : _name = name,
        assert(url != null),
        assert(url != '');
31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47

  final RemoteName _name;

  /// The name of the remote.
  String get name {
    switch (_name) {
      case RemoteName.upstream:
        return 'upstream';
      case RemoteName.mirror:
        return 'mirror';
    }
  }

  /// The URL of the remote.
  final String url;
}

48
/// A source code repository.
49
abstract class Repository {
50
  Repository({
51
    required this.name,
52
    required this.upstreamRemote,
53 54 55 56 57
    required this.processManager,
    required this.stdio,
    required this.platform,
    required this.fileSystem,
    required this.parentDirectory,
58
    this.initialRef,
59
    this.localUpstream = false,
60 61
    String? previousCheckoutLocation,
    this.mirrorRemote,
62 63
  })  : git = Git(processManager),
        assert(localUpstream != null),
64 65 66 67
        assert(upstreamRemote.url.isNotEmpty) {
    if (previousCheckoutLocation != null) {
      _checkoutDirectory = fileSystem.directory(previousCheckoutLocation);
      if (!_checkoutDirectory!.existsSync()) {
68 69
        throw ConductorException(
            'Provided previousCheckoutLocation $previousCheckoutLocation does not exist on disk!');
70 71
      }
      if (initialRef != null) {
72 73 74 75 76
        git.run(
          <String>['fetch', upstreamRemote.name],
          'Fetch ${upstreamRemote.name} to ensure we have latest refs',
          workingDirectory: _checkoutDirectory!.path,
        );
77 78 79 80 81 82 83 84
        git.run(
          <String>['checkout', '${upstreamRemote.name}/$initialRef'],
          'Checking out initialRef $initialRef',
          workingDirectory: _checkoutDirectory!.path,
        );
      }
    }
  }
85 86

  final String name;
87
  final Remote upstreamRemote;
88

89
  /// Remote for user's mirror.
90
  ///
91
  /// This value can be null, in which case attempting to access it will lead to
92
  /// a [ConductorException].
93
  final Remote? mirrorRemote;
94 95

  /// The initial ref (branch or commit name) to check out.
96
  final String? initialRef;
97 98 99 100 101 102 103 104 105 106
  final Git git;
  final ProcessManager processManager;
  final Stdio stdio;
  final Platform platform;
  final FileSystem fileSystem;
  final Directory parentDirectory;

  /// If the repository will be used as an upstream for a test repo.
  final bool localUpstream;

107
  Directory? _checkoutDirectory;
108

109
  /// Directory for the repository checkout.
110
  ///
111 112
  /// Since cloning a repository takes a long time, we do not ensure it is
  /// cloned on the filesystem until this getter is accessed.
113 114
  Directory get checkoutDirectory {
    if (_checkoutDirectory != null) {
115
      return _checkoutDirectory!;
116 117
    }
    _checkoutDirectory = parentDirectory.childDirectory(name);
118 119
    lazilyInitialize(_checkoutDirectory!);
    return _checkoutDirectory!;
120 121 122
  }

  /// Ensure the repository is cloned to disk and initialized with proper state.
123
  void lazilyInitialize(Directory checkoutDirectory) {
124
    if (checkoutDirectory.existsSync()) {
125 126
      stdio.printTrace('Deleting $name from ${checkoutDirectory.path}...');
      checkoutDirectory.deleteSync(recursive: true);
127
    }
128

129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148
    stdio.printTrace(
      'Cloning $name from ${upstreamRemote.url} to ${checkoutDirectory.path}...',
    );
    git.run(
      <String>[
        'clone',
        '--origin',
        upstreamRemote.name,
        '--',
        upstreamRemote.url,
        checkoutDirectory.path
      ],
      'Cloning $name repo',
      workingDirectory: parentDirectory.path,
    );
    if (mirrorRemote != null) {
      git.run(
        <String>['remote', 'add', mirrorRemote!.name, mirrorRemote!.url],
        'Adding remote ${mirrorRemote!.url} as ${mirrorRemote!.name}',
        workingDirectory: checkoutDirectory.path,
149
      );
150
      git.run(
151 152 153
        <String>['fetch', mirrorRemote!.name],
        'Fetching git remote ${mirrorRemote!.name}',
        workingDirectory: checkoutDirectory.path,
154
      );
155 156 157 158 159
    }
    if (localUpstream) {
      // These branches must exist locally for the repo that depends on it
      // to fetch and push to.
      for (final String channel in kReleaseChannels) {
160
        git.run(
161 162
          <String>['checkout', channel, '--'],
          'check out branch $channel locally',
163
          workingDirectory: checkoutDirectory.path,
164 165
        );
      }
166 167
    }

168 169
    if (initialRef != null) {
      git.run(
170
        <String>['checkout', '${upstreamRemote.name}/$initialRef'],
171
        'Checking out initialRef $initialRef',
172
        workingDirectory: checkoutDirectory.path,
173 174
      );
    }
175
    final String revision = reverseParse('HEAD');
176 177 178
    stdio.printTrace(
      'Repository $name is checked out at revision "$revision".',
    );
179 180
  }

181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200
  /// The URL of the remote named [remoteName].
  String remoteUrl(String remoteName) {
    assert(remoteName != null);
    return git.getOutput(
      <String>['remote', 'get-url', remoteName],
      'verify the URL of the $remoteName remote',
      workingDirectory: checkoutDirectory.path,
    );
  }

  /// Verify the repository's git checkout is clean.
  bool gitCheckoutClean() {
    final String output = git.getOutput(
      <String>['status', '--porcelain'],
      'check that the git checkout is clean',
      workingDirectory: checkoutDirectory.path,
    );
    return output == '';
  }

201 202 203 204 205 206 207 208 209
  /// Return the revision for the branch point between two refs.
  String branchPoint(String firstRef, String secondRef) {
    return git.getOutput(
      <String>['merge-base', firstRef, secondRef],
      'determine the merge base between $firstRef and $secondRef',
      workingDirectory: checkoutDirectory.path,
    ).trim();
  }

210 211 212 213 214 215 216 217 218
  /// Fetch all branches and associated commits and tags from [remoteName].
  void fetch(String remoteName) {
    git.run(
      <String>['fetch', remoteName, '--tags'],
      'fetch $remoteName --tags',
      workingDirectory: checkoutDirectory.path,
    );
  }

219 220 221 222
  /// Create (and checkout) a new branch based on the current HEAD.
  ///
  /// Runs `git checkout -b $branchName`.
  void newBranch(String branchName) {
223
    git.run(
224 225 226 227 228 229 230 231 232 233 234
      <String>['checkout', '-b', branchName],
      'create & checkout new branch $branchName',
      workingDirectory: checkoutDirectory.path,
    );
  }

  /// Check out the given ref.
  void checkout(String ref) {
    git.run(
      <String>['checkout', ref],
      'checkout ref',
235 236 237 238
      workingDirectory: checkoutDirectory.path,
    );
  }

239 240 241 242 243 244 245 246
  /// Obtain the version tag at the tip of a release branch.
  String getFullTag(
    String remoteName,
    String branchName, {
    bool exact = true,
  }) {
    // includes both stable (e.g. 1.2.3) and dev tags (e.g. 1.2.3-4.5.pre)
    const String glob = '*.*.*';
247
    // describe the latest dev release
248
    final String ref = 'refs/remotes/$remoteName/$branchName';
249
    return git.getOutput(
250 251 252 253 254 255 256 257
      <String>[
        'describe',
        '--match',
        glob,
        if (exact) '--exact-match',
        '--tags',
        ref,
      ],
258 259 260 261 262
      'obtain last released version number',
      workingDirectory: checkoutDirectory.path,
    );
  }

263 264 265
  /// List commits in reverse chronological order.
  List<String> revList(List<String> args) {
    return git
266 267 268
        .getOutput(<String>['rev-list', ...args],
            'rev-list with args ${args.join(' ')}',
            workingDirectory: checkoutDirectory.path)
269 270 271 272
        .trim()
        .split('\n');
  }

273 274 275 276 277 278
  /// Look up the commit for [ref].
  String reverseParse(String ref) {
    final String revisionHash = git.getOutput(
      <String>['rev-parse', ref],
      'look up the commit for the ref $ref',
      workingDirectory: checkoutDirectory.path,
279
    );
280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310
    assert(revisionHash.isNotEmpty);
    return revisionHash;
  }

  /// Determines if one ref is an ancestor for another.
  bool isAncestor(String possibleAncestor, String possibleDescendant) {
    final int exitcode = git.run(
      <String>[
        'merge-base',
        '--is-ancestor',
        possibleDescendant,
        possibleAncestor
      ],
      'verify $possibleAncestor is a direct ancestor of $possibleDescendant.',
      allowNonZeroExitCode: true,
      workingDirectory: checkoutDirectory.path,
    );
    return exitcode == 0;
  }

  /// Determines if a given commit has a tag.
  bool isCommitTagged(String commit) {
    final int exitcode = git.run(
      <String>['describe', '--exact-match', '--tags', commit],
      'verify $commit is already tagged',
      allowNonZeroExitCode: true,
      workingDirectory: checkoutDirectory.path,
    );
    return exitcode == 0;
  }

311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348
  /// Determines if a commit will cherry-pick to current HEAD without conflict.
  bool canCherryPick(String commit) {
    assert(
      gitCheckoutClean(),
      'cannot cherry-pick because git checkout ${checkoutDirectory.path} is not clean',
    );

    final int exitcode = git.run(
      <String>['cherry-pick', '--no-commit', commit],
      'attempt to cherry-pick $commit without committing',
      allowNonZeroExitCode: true,
      workingDirectory: checkoutDirectory.path,
    );

    final bool result = exitcode == 0;

    if (result == false) {
      stdio.printError(git.getOutput(
        <String>['diff'],
        'get diff of failed cherry-pick',
        workingDirectory: checkoutDirectory.path,
      ));
    }

    reset('HEAD');
    return result;
  }

  /// Cherry-pick a [commit] to the current HEAD.
  ///
  /// This method will throw a [GitException] if the command fails.
  void cherryPick(String commit) {
    assert(
      gitCheckoutClean(),
      'cannot cherry-pick because git checkout ${checkoutDirectory.path} is not clean',
    );

    git.run(
349 350
      <String>['cherry-pick', commit],
      'cherry-pick $commit',
351 352 353 354 355 356
      workingDirectory: checkoutDirectory.path,
    );
  }

  /// Resets repository HEAD to [ref].
  void reset(String ref) {
357
    git.run(
358 359
      <String>['reset', ref, '--hard'],
      'reset to $ref',
360 361 362 363 364
      workingDirectory: checkoutDirectory.path,
    );
  }

  /// Push [commit] to the release channel [branch].
365 366 367 368
  void pushRef({
    required String fromRef,
    required String remote,
    required String toRef,
369
    bool force = false,
370
    bool dryRun = false,
371
  }) {
372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391
    final List<String> args = <String>[
      'push',
      if (force) '--force',
      remote,
      '$fromRef:$toRef',
    ];
    final String command = <String>[
      'git',
      ...args,
    ].join(' ');
    if (dryRun) {
      stdio.printStatus('About to execute command: `$command`');
    } else {
      git.run(
        args,
        'update the release branch with the commit',
        workingDirectory: checkoutDirectory.path,
      );
      stdio.printStatus('Executed command: `$command`');
    }
392 393
  }

394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413
  String commit(
    String message, {
    bool addFirst = false,
  }) {
    assert(!message.contains("'"));
    if (addFirst) {
      git.run(
        <String>['add', '--all'],
        'add all changes to the index',
        workingDirectory: checkoutDirectory.path,
      );
    }
    git.run(
      <String>['commit', "--message='$message'"],
      'commit changes',
      workingDirectory: checkoutDirectory.path,
    );
    return reverseParse('HEAD');
  }

414 415 416 417 418 419 420 421 422 423 424 425
  /// Create an empty commit and return the revision.
  @visibleForTesting
  String authorEmptyCommit([String message = 'An empty commit']) {
    git.run(
      <String>[
        '-c',
        'user.name=Conductor',
        '-c',
        'user.email=conductor@flutter.dev',
        'commit',
        '--allow-empty',
        '-m',
426
        "'$message'",
427 428 429 430 431 432 433 434 435 436 437 438 439 440
      ],
      'create an empty commit',
      workingDirectory: checkoutDirectory.path,
    );
    return reverseParse('HEAD');
  }

  /// Create a new clone of the current repository.
  ///
  /// The returned repository will inherit all properties from this one, except
  /// for the upstream, which will be the path to this repository on disk.
  ///
  /// This method is for testing purposes.
  @visibleForTesting
441 442 443 444 445 446 447
  Repository cloneRepository(String cloneName);
}

class FrameworkRepository extends Repository {
  FrameworkRepository(
    this.checkouts, {
    String name = 'framework',
448
    Remote upstreamRemote = const Remote(
449
        name: RemoteName.upstream, url: FrameworkRepository.defaultUpstream),
450
    bool localUpstream = false,
451
    String? previousCheckoutLocation,
452
    String? initialRef,
453
    Remote? mirrorRemote,
454 455
  }) : super(
          name: name,
456 457
          upstreamRemote: upstreamRemote,
          mirrorRemote: mirrorRemote,
458
          initialRef: initialRef,
459 460 461 462 463 464
          fileSystem: checkouts.fileSystem,
          localUpstream: localUpstream,
          parentDirectory: checkouts.directory,
          platform: checkouts.platform,
          processManager: checkouts.processManager,
          stdio: checkouts.stdio,
465
          previousCheckoutLocation: previousCheckoutLocation,
466 467 468 469 470 471 472 473 474
        );

  /// A [FrameworkRepository] with the host conductor's repo set as upstream.
  ///
  /// This is useful when testing a commit that has not been merged upstream
  /// yet.
  factory FrameworkRepository.localRepoAsUpstream(
    Checkouts checkouts, {
    String name = 'framework',
475
    String? previousCheckoutLocation,
476
    required String upstreamPath,
477 478 479 480
  }) {
    return FrameworkRepository(
      checkouts,
      name: name,
481
      upstreamRemote: Remote(
482 483 484
        name: RemoteName.upstream,
        url: 'file://$upstreamPath/',
      ),
485
      localUpstream: false,
486
      previousCheckoutLocation: previousCheckoutLocation,
487 488 489 490 491 492 493
    );
  }

  final Checkouts checkouts;
  static const String defaultUpstream =
      'https://github.com/flutter/flutter.git';

494 495
  static const String defaultBranch = 'master';

496 497 498 499 500 501
  String get cacheDirectory => fileSystem.path.join(
        checkoutDirectory.path,
        'bin',
        'cache',
      );

502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522
  /// Tag [commit] and push the tag to the remote.
  void tag(String commit, String tagName, String remote) {
    assert(commit.isNotEmpty);
    assert(tagName.isNotEmpty);
    assert(remote.isNotEmpty);
    stdio.printStatus('About to tag commit $commit as $tagName...');
    git.run(
      <String>['tag', tagName, commit],
      'tag the commit with the version label',
      workingDirectory: checkoutDirectory.path,
    );
    stdio.printStatus('Tagging successful.');
    stdio.printStatus('About to push $tagName to remote $remote...');
    git.run(
      <String>['push', remote, tagName],
      'publish the tag to the repo',
      workingDirectory: checkoutDirectory.path,
    );
    stdio.printStatus('Tag push successful.');
  }

523
  @override
524
  Repository cloneRepository(String? cloneName) {
525 526
    assert(localUpstream);
    cloneName ??= 'clone-of-$name';
527 528
    return FrameworkRepository(
      checkouts,
529
      name: cloneName,
530
      upstreamRemote: Remote(
531
          name: RemoteName.upstream, url: 'file://${checkoutDirectory.path}/'),
532 533
    );
  }
534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563

  void _ensureToolReady() {
    final File toolsStamp =
        fileSystem.directory(cacheDirectory).childFile('flutter_tools.stamp');
    if (toolsStamp.existsSync()) {
      final String toolsStampHash = toolsStamp.readAsStringSync().trim();
      final String repoHeadHash = reverseParse('HEAD');
      if (toolsStampHash == repoHeadHash) {
        return;
      }
    }

    stdio.printTrace('Building tool...');
    // Build tool
    processManager.runSync(<String>[
      fileSystem.path.join(checkoutDirectory.path, 'bin', 'flutter'),
      'help',
    ]);
  }

  io.ProcessResult runFlutter(List<String> args) {
    _ensureToolReady();

    return processManager.runSync(<String>[
      fileSystem.path.join(checkoutDirectory.path, 'bin', 'flutter'),
      ...args,
    ]);
  }

  @override
564 565
  void checkout(String ref) {
    super.checkout(ref);
566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581
    // The tool will overwrite old cached artifacts, but not delete unused
    // artifacts from a previous version. Thus, delete the entire cache and
    // re-populate.
    final Directory cache = fileSystem.directory(cacheDirectory);
    if (cache.existsSync()) {
      stdio.printTrace('Deleting cache...');
      cache.deleteSync(recursive: true);
    }
    _ensureToolReady();
  }

  Version flutterVersion() {
    // Check version
    final io.ProcessResult result =
        runFlutter(<String>['--version', '--machine']);
    final Map<String, dynamic> versionJson = jsonDecode(
582
      stdoutToString(result.stdout),
583 584 585
    ) as Map<String, dynamic>;
    return Version.fromString(versionJson['frameworkVersion'] as String);
  }
586 587 588 589 590 591 592 593 594 595 596 597 598 599 600

  void updateEngineRevision(
    String newEngine, {
    @visibleForTesting File? engineVersionFile,
  }) {
    assert(newEngine.isNotEmpty);
    engineVersionFile ??= checkoutDirectory
        .childDirectory('bin')
        .childDirectory('internal')
        .childFile('engine.version');
    assert(engineVersionFile.existsSync());
    final String oldEngine = engineVersionFile.readAsStringSync();
    stdio.printStatus('Updating engine revision from $oldEngine to $newEngine');
    engineVersionFile.writeAsStringSync(newEngine.trim(), flush: true);
  }
601 602
}

603 604 605 606 607 608 609 610 611 612
/// A wrapper around the host repository that is executing the conductor.
///
/// [Repository] methods that mutate the underlying repository will throw a
/// [ConductorException].
class HostFrameworkRepository extends FrameworkRepository {
  HostFrameworkRepository({
    required Checkouts checkouts,
    String name = 'host-framework',
    required String upstreamPath,
  }) : super(
613 614 615 616 617 618 619 620
          checkouts,
          name: name,
          upstreamRemote: Remote(
            name: RemoteName.upstream,
            url: 'file://$upstreamPath/',
          ),
          localUpstream: false,
        ) {
621 622 623 624 625 626 627 628
    _checkoutDirectory = checkouts.fileSystem.directory(upstreamPath);
  }

  @override
  Directory get checkoutDirectory => _checkoutDirectory!;

  @override
  void newBranch(String branchName) {
629 630
    throw ConductorException(
        'newBranch not implemented for the host repository');
631 632 633 634
  }

  @override
  void checkout(String ref) {
635 636
    throw ConductorException(
        'checkout not implemented for the host repository');
637 638 639 640
  }

  @override
  String cherryPick(String commit) {
641 642
    throw ConductorException(
        'cherryPick not implemented for the host repository');
643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659
  }

  @override
  String reset(String ref) {
    throw ConductorException('reset not implemented for the host repository');
  }

  @override
  void tag(String commit, String tagName, String remote) {
    throw ConductorException('tag not implemented for the host repository');
  }

  void updateChannel(
    String commit,
    String remote,
    String branch, {
    bool force = false,
660
    bool dryRun = false,
661
  }) {
662 663
    throw ConductorException(
        'updateChannel not implemented for the host repository');
664 665 666 667 668 669 670 671 672 673
  }

  @override
  String authorEmptyCommit([String message = 'An empty commit']) {
    throw ConductorException(
      'authorEmptyCommit not implemented for the host repository',
    );
  }
}

674 675 676 677 678
class EngineRepository extends Repository {
  EngineRepository(
    this.checkouts, {
    String name = 'engine',
    String initialRef = EngineRepository.defaultBranch,
679
    Remote upstreamRemote = const Remote(
680 681
        name: RemoteName.upstream, url: EngineRepository.defaultUpstream),
    bool localUpstream = false,
682 683
    String? previousCheckoutLocation,
    Remote? mirrorRemote,
684 685
  }) : super(
          name: name,
686 687
          upstreamRemote: upstreamRemote,
          mirrorRemote: mirrorRemote,
688 689 690 691 692 693 694
          initialRef: initialRef,
          fileSystem: checkouts.fileSystem,
          localUpstream: localUpstream,
          parentDirectory: checkouts.directory,
          platform: checkouts.platform,
          processManager: checkouts.processManager,
          stdio: checkouts.stdio,
695
          previousCheckoutLocation: previousCheckoutLocation,
696 697 698 699 700 701 702
        );

  final Checkouts checkouts;

  static const String defaultUpstream = 'https://github.com/flutter/engine.git';
  static const String defaultBranch = 'master';

703 704 705 706 707 708 709 710 711
  /// Update the `dart_revision` entry in the DEPS file.
  void updateDartRevision(
    String newRevision, {
    @visibleForTesting File? depsFile,
  }) {
    assert(newRevision.length == 40);
    depsFile ??= checkoutDirectory.childFile('DEPS');
    final String fileContent = depsFile.readAsStringSync();
    final RegExp dartPattern = RegExp("[ ]+'dart_revision': '([a-z0-9]{40})',");
712 713
    final Iterable<RegExpMatch> allMatches =
        dartPattern.allMatches(fileContent);
714 715
    if (allMatches.length != 1) {
      throw ConductorException(
716 717 718
          'Unexpected content in the DEPS file at ${depsFile.path}\n'
          'Expected to find pattern ${dartPattern.pattern} 1 times, but got '
          '${allMatches.length}.');
719 720 721 722 723 724
    }
    final String updatedFileContent = fileContent.replaceFirst(
      dartPattern,
      "  'dart_revision': '$newRevision',",
    );

725
    depsFile.writeAsStringSync(updatedFileContent, flush: true);
726 727
  }

728
  @override
729
  Repository cloneRepository(String? cloneName) {
730 731 732 733 734
    assert(localUpstream);
    cloneName ??= 'clone-of-$name';
    return EngineRepository(
      checkouts,
      name: cloneName,
735
      upstreamRemote: Remote(
736 737 738 739 740
          name: RemoteName.upstream, url: 'file://${checkoutDirectory.path}/'),
    );
  }
}

741 742 743 744 745 746 747 748
/// An enum of all the repositories that the Conductor supports.
enum RepositoryType {
  framework,
  engine,
}

class Checkouts {
  Checkouts({
749 750 751 752 753
    required this.fileSystem,
    required this.platform,
    required this.processManager,
    required this.stdio,
    required Directory parentDirectory,
754
    String directoryName = 'flutter_conductor_checkouts',
755
  }) : directory = parentDirectory.childDirectory(directoryName) {
756 757 758 759 760
    if (!directory.existsSync()) {
      directory.createSync(recursive: true);
    }
  }

761
  final Directory directory;
762
  final FileSystem fileSystem;
763
  final Platform platform;
764
  final ProcessManager processManager;
765
  final Stdio stdio;
766
}