| // Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file |
| // for details. 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:io'; |
| |
| import 'package:cli_config/cli_config.dart'; |
| import 'package:logging/logging.dart'; |
| |
| import 'bisection_config.dart'; |
| import 'run_process.dart'; |
| |
| Future<void> runMain(List<String> args) async { |
| if (args.contains('--help')) { |
| print(BisectionConfig.helpMessage()); |
| return; |
| } |
| final config = BisectionConfig.fromConfig( |
| await Config.fromArguments(arguments: args), |
| ); |
| await runBisection(config); |
| } |
| |
| Future<void> runBisection(BisectionConfig config) async { |
| final name = config.name; |
| final startHash = config.start; |
| final endHash = config.end; |
| final testCommands = config.testCommands; |
| final failurePattern = config.failurePattern; |
| final sdkCheckout = config.sdkPath; |
| |
| final logsDir = |
| Directory.current.uri.resolve('.dart_tool/bisect_dart/$name/logs/'); |
| await Directory.fromUri(logsDir).create(recursive: true); |
| final logFileUri = logsDir.resolve('full.txt'); |
| final logFile = File.fromUri(logFileUri); |
| if (await logFile.exists()) { |
| await logFile.delete(); |
| } |
| final logger = _mainLogger('', logFileUri); |
| logger.info('Writing detailed log to ${logFileUri.toFilePath()}.'); |
| logger.config('Bisection configuration: $config.'); |
| |
| final ancestorHash = |
| await _ascestorCommitHash(startHash, endHash, sdkCheckout, logger); |
| final descendantHash = ancestorHash == startHash ? endHash : startHash; |
| final hashBeforeRange = |
| await _parentCommitHash(ancestorHash, sdkCheckout, logger); |
| logger.info('Commit range $hashBeforeRange...$descendantHash.'); |
| |
| await _ensureSdkRepo(sdkCheckout, logger); |
| |
| logger.info('Ensuring failure reproduces on $descendantHash.'); |
| final shouldFail = await _checkCommit( |
| descendantHash, testCommands, failurePattern, sdkCheckout, logger); |
| if (!shouldFail) { |
| throw Exception('$descendantHash failed to reproduce the error.'); |
| } |
| |
| logger.info('Ensuring failure does not reproduce on $hashBeforeRange.'); |
| final shouldSucceed = await _checkCommit( |
| hashBeforeRange, testCommands, failurePattern, sdkCheckout, logger); |
| if (shouldSucceed) { |
| throw Exception('$ancestorHash failed to reproduced the error.'); |
| } |
| |
| final commitHashes = await _commitHashesInRange( |
| ancestorHash, descendantHash, sdkCheckout, logger); |
| final regressionCommit = await _bisect( |
| commitHashes, testCommands, failurePattern, sdkCheckout, logger); |
| logger.info('Bisected to $regressionCommit.'); |
| } |
| |
| Future<String> _bisect( |
| List<String> commitHashes, |
| List<String> testCommands, |
| Pattern failurePattern, |
| Uri sdkCheckout, |
| Logger logger, |
| ) async { |
| if (commitHashes.length == 1) { |
| return commitHashes.single; |
| } |
| final numCommits = commitHashes.length; |
| final pivotIndex = numCommits ~/ 2; |
| final pivot = commitHashes[pivotIndex]; |
| final parentHashBeforeLast = |
| await _parentCommitHash(commitHashes.last, sdkCheckout, logger); |
| logger.info( |
| 'Bisecting $parentHashBeforeLast...${commitHashes.first} ' |
| '($numCommits commits). Trying $pivot.', |
| ); |
| final commitResult = await _checkCommit( |
| pivot, |
| testCommands, |
| failurePattern, |
| sdkCheckout, |
| logger, |
| ); |
| List<String> remainingCommits; |
| if (commitResult) { |
| // Reproduces on pivot, so it must be in the older half of commits. |
| remainingCommits = commitHashes.skip(pivotIndex).toList(); |
| } else { |
| remainingCommits = commitHashes.take(pivotIndex).toList(); |
| } |
| return await _bisect( |
| remainingCommits, testCommands, failurePattern, sdkCheckout, logger); |
| } |
| |
| /// Returns true if the commit has the [failurePattern]. |
| Future<bool> _checkCommit(String hash, List<String> testCommands, |
| Pattern failurePattern, Uri sdkCheckout, Logger logger) async { |
| logger.config('Testing $hash.'); |
| await _gitCheckout(hash, sdkCheckout, logger); |
| await _gclientSync(sdkCheckout, logger); |
| final testOutput = await _runTest(testCommands, sdkCheckout, logger); |
| final matches = failurePattern.allMatches(testOutput).toList(); |
| final foundFailure = matches.isNotEmpty; |
| if (foundFailure) { |
| logger.info('Commit $hash, reproduces failure.'); |
| } else { |
| logger.info('Commit $hash, does not reproduce failure.'); |
| } |
| return foundFailure; |
| } |
| |
| Future<void> _ensureSdkRepo(Uri sdkCheckout, Logger logger) async { |
| logger.info('Ensuring SDK repo in ${sdkCheckout.toFilePath()}.'); |
| final workDir = Directory.fromUri(sdkCheckout).parent; |
| if (!await workDir.exists()) { |
| await workDir.create(recursive: true); |
| await runProcess( |
| executable: Uri.file('fetch'), |
| arguments: ['dart'], |
| logger: logger, |
| workingDirectory: workDir.uri, |
| ); |
| } else { |
| await runProcess( |
| executable: Uri.file('git'), |
| arguments: ['stash', '--include-untracked'], |
| logger: logger, |
| workingDirectory: sdkCheckout, |
| ); |
| await runProcess( |
| executable: Uri.file('git'), |
| arguments: ['fetch'], |
| logger: logger, |
| workingDirectory: sdkCheckout, |
| ); |
| } |
| } |
| |
| Future<void> _gitCheckout(String hash, Uri sdkCheckout, Logger logger) { |
| return runProcess( |
| executable: Uri.file('git'), |
| arguments: ['checkout', hash], |
| logger: logger, |
| workingDirectory: sdkCheckout, |
| ); |
| } |
| |
| Future<void> _gclientSync(Uri sdkCheckout, Logger logger) { |
| return runProcess( |
| executable: Uri.file('gclient'), |
| arguments: ['sync', '-D'], |
| logger: logger, |
| workingDirectory: sdkCheckout, |
| ); |
| } |
| |
| Future<String> _runTest( |
| List<String> testCommands, Uri sdkCheckout, Logger logger) async { |
| var output = ''; |
| for (final command in testCommands) { |
| final commandSplit = command.split(' '); |
| final result = await runProcess( |
| executable: Uri.file(commandSplit.first), |
| arguments: commandSplit.sublist(1), |
| logger: logger, |
| workingDirectory: sdkCheckout, |
| captureOutput: true, |
| ); |
| output += result.stdout; |
| output += result.stderr; |
| } |
| return output; |
| } |
| |
| /// Ordered from now to old. |
| /// |
| /// Inclusive. |
| Future<List<String>> _commitHashesInRange(String ancestorHash, |
| String descendantHash, Uri sdkCheckout, Logger logger) async { |
| final result = await runProcess( |
| executable: Uri.file('git'), |
| arguments: [ |
| 'log', |
| '--pretty=format:"%h"', |
| '$ancestorHash~...$descendantHash', |
| ], |
| captureOutput: true, |
| logger: logger, |
| workingDirectory: sdkCheckout, |
| ); |
| return result.stdout.trim().replaceAll('"', '').split('\n'); |
| } |
| |
| Future<bool> _isAncestorOf(String commitAncestor, String commitDescendant, |
| Uri sdkCheckout, Logger logger) async { |
| final result = await runProcess( |
| executable: Uri.file('git'), |
| arguments: [ |
| 'merge-base', |
| commitAncestor, |
| '--is-ancestor', |
| commitDescendant, |
| ], |
| captureOutput: true, |
| logger: logger, |
| workingDirectory: sdkCheckout, |
| ); |
| return result.exitCode == 0; |
| } |
| |
| Future<String> _ascestorCommitHash( |
| String commit1, String commit2, Uri sdkCheckout, Logger logger) async { |
| if (await _isAncestorOf(commit1, commit2, sdkCheckout, logger)) { |
| return commit1; |
| } |
| if (await _isAncestorOf(commit2, commit1, sdkCheckout, logger)) { |
| return commit2; |
| } |
| throw Exception( |
| 'Commits $commit1 and $commit2 are not ancestors of each other.'); |
| } |
| |
| Future<String> _parentCommitHash( |
| String commitHash, Uri sdkCheckout, Logger logger) async { |
| final result = await runProcess( |
| executable: Uri.file('git'), |
| arguments: [ |
| 'log', |
| '--pretty=format:"%h"', |
| '$commitHash~1...$commitHash~2', |
| ], |
| captureOutput: true, |
| logger: logger, |
| workingDirectory: sdkCheckout, |
| ); |
| return result.stdout.trim().replaceAll('"', ''); |
| } |
| |
| Logger _mainLogger(String name, Uri filePath) { |
| final file = File.fromUri(filePath); |
| return Logger('') |
| ..level = Level.ALL |
| ..onRecord.listen((record) { |
| if (record.level >= Level.INFO) { |
| print(record.message); |
| } |
| file.writeAsStringSync( |
| '${record.message}\n', |
| mode: FileMode.append, |
| ); |
| }); |
| } |