Add a new `dt presubmit` tool. (#9751)
diff --git a/tool/lib/commands/presubmit.dart b/tool/lib/commands/presubmit.dart
new file mode 100644
index 0000000..c051803
--- /dev/null
+++ b/tool/lib/commands/presubmit.dart
@@ -0,0 +1,156 @@
+// Copyright 2026 The Flutter Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
+
+import 'dart:async';
+import 'dart:io';
+
+import 'package:args/command_runner.dart';
+import 'package:cli_util/cli_logging.dart';
+import 'package:io/io.dart';
+import 'package:meta/meta.dart';
+import 'package:path/path.dart' as path;
+
+import '../model.dart';
+import '../utils.dart';
+
+class PresubmitCommand extends Command {
+ PresubmitCommand({@visibleForTesting this.processManager}) {
+ argParser.addFlag(
+ 'fix',
+ help: 'Apply dart fixes and formatting.',
+ defaultsTo: false,
+ negatable: false,
+ );
+ }
+
+ ProcessManager? processManager;
+
+ @override
+ String get name => 'presubmit';
+
+ @override
+ String get description =>
+ 'Run repo checks, analysis, fix, and format on all packages.';
+
+ @override
+ Future run() async {
+ final log = Logger.standard();
+ final repo = DevToolsRepo.getInstance();
+ final pm = processManager ?? ProcessManager();
+ final fix = argResults!['fix'] as bool;
+
+ log.stdout('Running pub get...');
+ final pubGetResult = await runner?.run(['pub-get']);
+ if (pubGetResult is int && pubGetResult != 0) {
+ log.stderr('Pub get failed. Exiting early.');
+ return 1;
+ }
+
+ final packages = repo.getPackages(includeSubdirectories: false);
+ int failureCount = 0;
+
+ if (fix) {
+ log.stdout('Running Dart Fix and Format...');
+ for (final p in packages) {
+ if (!p.hasAnyDartCode) continue;
+
+ final progress = log.progress(' ${p.relativePath}');
+
+ final fixProcess = await pm.runProcess(
+ CliCommand.dart(['fix', '--apply'], throwOnException: false),
+ workingDirectory: p.packagePath,
+ );
+
+ final pathsToFormat = _getPathsToFormat(p);
+
+ final formatProcess = await pm.runProcess(
+ CliCommand.dart(['format', ...pathsToFormat], throwOnException: false),
+ workingDirectory: p.packagePath,
+ );
+
+ if (fixProcess.exitCode == 0 && formatProcess.exitCode == 0) {
+ progress.finish(showTiming: true);
+ } else {
+ failureCount++;
+ progress.finish(message: 'failed');
+ }
+ }
+
+ if (failureCount > 0) {
+ log.stderr('Presubmit failed.');
+ log.stderr(' Fix or Format failed on $failureCount packages.');
+ return 1;
+ }
+ }
+
+ log.stdout('Running Repo Check...');
+ final repoCheckResult = await runner?.run(['repo-check']);
+ if (repoCheckResult is int && repoCheckResult != 0) {
+ log.stderr('Repo checks failed. Exiting early.');
+ return 1;
+ }
+
+ log.stdout('Running Analyze...');
+ final analyzeResult = await runner?.run(['analyze']);
+ if (analyzeResult is int && analyzeResult != 0) {
+ log.stderr('Analysis failed. Exiting early.');
+ return 1;
+ }
+
+ if (!fix) {
+ log.stdout('Running Dart Format Check...');
+ for (final p in packages) {
+ if (!p.hasAnyDartCode) continue;
+
+ final progress = log.progress(' ${p.relativePath}');
+
+ final pathsToFormat = _getPathsToFormat(p);
+
+ final formatProcess = await pm.runProcess(
+ CliCommand.dart(
+ ['format', '--output=none', '--set-exit-if-changed', ...pathsToFormat],
+ throwOnException: false,
+ ),
+ workingDirectory: p.packagePath,
+ );
+
+ if (formatProcess.exitCode == 0) {
+ progress.finish(showTiming: true);
+ } else {
+ failureCount++;
+ progress.finish(message: 'failed');
+ }
+ }
+
+ if (failureCount > 0) {
+ log.stderr('Presubmit failed.');
+ log.stderr(' Formatting issues found in $failureCount packages.');
+ return 1;
+ }
+ }
+
+ log.stdout('Presubmit passed!');
+ return 0;
+ }
+
+ List<String> _getPathsToFormat(Package p) {
+ final pathsToFormat = <String>[];
+ if (p.relativePath == 'tool') {
+ final children = Directory(p.packagePath).listSync();
+ for (final entity in children) {
+ final name = path.basename(entity.path);
+ if (name.startsWith('.')) continue;
+ if (name == 'flutter-sdk') continue;
+ if (entity is Directory) {
+ pathsToFormat.add(name);
+ } else if (entity is File && name.endsWith('.dart')) {
+ pathsToFormat.add(name);
+ }
+ }
+ } else {
+ pathsToFormat.add('.');
+ }
+ return pathsToFormat;
+ }
+}
diff --git a/tool/lib/devtools_command_runner.dart b/tool/lib/devtools_command_runner.dart
index 05f8243..7d59544 100644
--- a/tool/lib/devtools_command_runner.dart
+++ b/tool/lib/devtools_command_runner.dart
@@ -18,6 +18,7 @@
import 'commands/analyze.dart';
import 'commands/list.dart';
+import 'commands/presubmit.dart';
import 'commands/pub_get.dart';
import 'commands/release_helper.dart';
import 'commands/repo_check.dart';
@@ -37,6 +38,7 @@
addCommand(FixGoldensCommand());
addCommand(GenerateCodeCommand());
addCommand(ListCommand());
+ addCommand(PresubmitCommand());
addCommand(PubGetCommand());
addCommand(ReleaseHelperCommand());
addCommand(ReleaseNotesCommand());
diff --git a/tool/pubspec.yaml b/tool/pubspec.yaml
index e93e984..90ccf11 100644
--- a/tool/pubspec.yaml
+++ b/tool/pubspec.yaml
@@ -18,6 +18,7 @@
cli_util: ^0.4.1
collection: ^1.19.0
io: ^1.0.4
+ meta: ^1.18.0
path: ^1.9.0
yaml: ^3.1.2
diff --git a/tool/test/command_test_utils.dart b/tool/test/command_test_utils.dart
new file mode 100644
index 0000000..fd9a362
--- /dev/null
+++ b/tool/test/command_test_utils.dart
@@ -0,0 +1,128 @@
+// Copyright 2026 The Flutter Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
+
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:args/command_runner.dart';
+import 'package:io/io.dart';
+
+class MockProcessManager implements ProcessManager {
+ MockProcessManager({this.onSpawn});
+
+ final Future<Process> Function(
+ String executable,
+ Iterable<String> arguments, {
+ String? workingDirectory,
+ Map<String, String>? environment,
+ bool includeParentEnvironment,
+ bool runInShell,
+ ProcessStartMode mode,
+ })?
+ onSpawn;
+
+ @override
+ Future<Process> spawn(
+ String executable,
+ Iterable<String> arguments, {
+ String? workingDirectory,
+ Map<String, String>? environment,
+ bool includeParentEnvironment = true,
+ bool runInShell = false,
+ ProcessStartMode mode = ProcessStartMode.normal,
+ }) async {
+ if (onSpawn != null) {
+ return onSpawn!(
+ executable,
+ arguments,
+ workingDirectory: workingDirectory,
+ environment: environment,
+ includeParentEnvironment: includeParentEnvironment,
+ runInShell: runInShell,
+ mode: mode,
+ );
+ }
+ return MockProcess();
+ }
+
+ @override
+ Future<Process> spawnBackground(
+ String executable,
+ Iterable<String> arguments, {
+ String? workingDirectory,
+ Map<String, String>? environment,
+ bool includeParentEnvironment = true,
+ bool runInShell = false,
+ ProcessStartMode mode = ProcessStartMode.normal,
+ }) async {
+ throw UnimplementedError();
+ }
+
+ @override
+ Future<Process> spawnDetached(
+ String executable,
+ Iterable<String> arguments, {
+ String? workingDirectory,
+ Map<String, String>? environment,
+ bool includeParentEnvironment = true,
+ bool runInShell = false,
+ ProcessStartMode mode = ProcessStartMode.normal,
+ }) async {
+ throw UnimplementedError();
+ }
+}
+
+class MockProcess implements Process {
+ MockProcess({
+ this.exitCodeValue = 0,
+ this.stdoutString = '',
+ this.stderrString = '',
+ });
+
+ final int exitCodeValue;
+ final String stdoutString;
+ final String stderrString;
+
+ @override
+ Future<int> get exitCode => Future.value(exitCodeValue);
+
+ @override
+ Stream<List<int>> get stdout => Stream.value(utf8.encode(stdoutString));
+
+ @override
+ Stream<List<int>> get stderr => Stream.value(utf8.encode(stderrString));
+
+ @override
+ bool kill([ProcessSignal signal = ProcessSignal.sigterm]) => true;
+
+ @override
+ int get pid => 0;
+
+ @override
+ IOSink get stdin => throw UnimplementedError();
+}
+
+class TestCommandRunner extends CommandRunner {
+ TestCommandRunner() : super('test', 'test description');
+
+ void addDummyCommand(String name, [int exitCode = 0]) {
+ addCommand(DummyCommand(name, exitCode));
+ }
+}
+
+class DummyCommand extends Command {
+ DummyCommand(this.name, this.exitCodeValue);
+
+ @override
+ final String name;
+
+ @override
+ String get description => 'Dummy command for testing';
+
+ final int exitCodeValue;
+
+ @override
+ Future<int> run() async => exitCodeValue;
+}
diff --git a/tool/test/presubmit_test.dart b/tool/test/presubmit_test.dart
new file mode 100644
index 0000000..fff0813
--- /dev/null
+++ b/tool/test/presubmit_test.dart
@@ -0,0 +1,176 @@
+// Copyright 2026 The Flutter Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
+
+import 'dart:io';
+
+import 'package:devtools_tool/commands/presubmit.dart';
+import 'package:devtools_tool/model.dart';
+import 'package:test/test.dart';
+
+import 'command_test_utils.dart';
+
+void main() {
+ group('PresubmitCommand', () {
+ setUp(() {
+ try {
+ FlutterSdk.useFromCurrentVm();
+ } catch (_) {
+ FlutterSdk.useFromPathEnvironmentVariable();
+ }
+ });
+
+ test('succeeds when all steps pass', () async {
+ final runner = TestCommandRunner();
+ runner.addDummyCommand('pub-get');
+ runner.addDummyCommand('repo-check');
+ runner.addDummyCommand('analyze');
+ runner.addCommand(PresubmitCommand(processManager: MockProcessManager()));
+
+ final result = await runner.run(['presubmit']);
+ expect(result, equals(0));
+ });
+
+ test('runs fix and format when --fix is passed', () async {
+ final runner = TestCommandRunner();
+ runner.addDummyCommand('pub-get');
+ runner.addDummyCommand('repo-check');
+ runner.addDummyCommand('analyze');
+
+ final capturedArgs = <List<String>>[];
+ final mockPm = MockProcessManager(
+ onSpawn: (
+ executable,
+ arguments, {
+ workingDirectory,
+ environment,
+ includeParentEnvironment = true,
+ runInShell = false,
+ mode = ProcessStartMode.normal,
+ }) async {
+ capturedArgs.add(arguments.toList());
+ return MockProcess();
+ },
+ );
+
+ runner.addCommand(PresubmitCommand(processManager: mockPm));
+
+ final result = await runner.run(['presubmit', '--fix']);
+ expect(result, equals(0));
+
+ final hasFix = capturedArgs.any((args) => args.contains('fix'));
+ expect(hasFix, isTrue);
+
+ final formatArgs = capturedArgs.firstWhere(
+ (args) => args.contains('format'),
+ orElse: () => [],
+ );
+ expect(formatArgs, isNotEmpty);
+ expect(formatArgs.contains('--output=none'), isFalse);
+ expect(formatArgs.contains('--set-exit-if-changed'), isFalse);
+ });
+
+ test('fails fast if pub-get fails', () async {
+ final runner = TestCommandRunner();
+ runner.addDummyCommand('pub-get', 1); // fails
+ runner.addDummyCommand('repo-check');
+ runner.addDummyCommand('analyze');
+ runner.addCommand(PresubmitCommand(processManager: MockProcessManager()));
+
+ final result = await runner.run(['presubmit']);
+ expect(result, equals(1));
+ });
+
+ test('fails fast if repo-check fails', () async {
+ final runner = TestCommandRunner();
+ runner.addDummyCommand('pub-get');
+ runner.addDummyCommand('repo-check', 1); // fails
+ runner.addDummyCommand('analyze');
+ runner.addCommand(PresubmitCommand(processManager: MockProcessManager()));
+
+ final result = await runner.run(['presubmit']);
+ expect(result, equals(1));
+ });
+
+ test('fails fast if analyze fails', () async {
+ final runner = TestCommandRunner();
+ runner.addDummyCommand('pub-get');
+ runner.addDummyCommand('repo-check');
+ runner.addDummyCommand('analyze', 1); // fails
+ runner.addCommand(PresubmitCommand(processManager: MockProcessManager()));
+
+ final result = await runner.run(['presubmit']);
+ expect(result, equals(1));
+ });
+
+ test('fails if dart format check fails without --fix', () async {
+ final runner = TestCommandRunner();
+ runner.addDummyCommand('pub-get');
+ runner.addDummyCommand('repo-check');
+ runner.addDummyCommand('analyze');
+
+ final mockPm = MockProcessManager(
+ onSpawn: (
+ executable,
+ arguments, {
+ workingDirectory,
+ environment,
+ includeParentEnvironment = true,
+ runInShell = false,
+ mode = ProcessStartMode.normal,
+ }) async {
+ if (arguments.contains('format') &&
+ arguments.contains('--set-exit-if-changed')) {
+ return MockProcess(exitCodeValue: 1);
+ }
+ return MockProcess();
+ },
+ );
+
+ runner.addCommand(PresubmitCommand(processManager: mockPm));
+
+ final result = await runner.run(['presubmit']);
+ expect(result, equals(1));
+ });
+
+ test('filters files for tool package formatting', () async {
+ final runner = TestCommandRunner();
+ runner.addDummyCommand('pub-get');
+ runner.addDummyCommand('repo-check');
+ runner.addDummyCommand('analyze');
+
+ final capturedArgs = <List<String>>[];
+ final mockPm = MockProcessManager(
+ onSpawn: (
+ executable,
+ arguments, {
+ workingDirectory,
+ environment,
+ includeParentEnvironment = true,
+ runInShell = false,
+ mode = ProcessStartMode.normal,
+ }) async {
+ capturedArgs.add(arguments.toList());
+ return MockProcess();
+ },
+ );
+
+ runner.addCommand(PresubmitCommand(processManager: mockPm));
+
+ await runner.run(['presubmit']);
+
+ // Find the format command for the tool package.
+ // It should contain 'lib' (since 'lib' is one of its children) and NOT
+ // '.' (which is used for other packages). Or it should contain multiple
+ // paths.
+ final toolFormatArgs = capturedArgs.firstWhere(
+ // 'dart', 'format', and at least two paths
+ (args) => args.contains('format') && args.length > 3,
+ orElse: () => [],
+ );
+
+ expect(toolFormatArgs, isNotEmpty);
+ expect(toolFormatArgs.contains('flutter-sdk'), isFalse);
+ });
+ });
+}