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);
+    });
+  });
+}