[flutter_plugin_tools] Adds update-excerpts command (#5339)

diff --git a/.cirrus.yml b/.cirrus.yml
index 66e8336..c256ab1 100644
--- a/.cirrus.yml
+++ b/.cirrus.yml
@@ -155,6 +155,10 @@
       analyze_script:
         - ./script/tool_runner.sh analyze --skip-if-not-supporting-flutter-version="$CHANNEL" --custom-analysis=script/configs/custom_analysis.yaml
         - echo "If this test fails, the minumum Flutter version should be updated"
+    - name: readme_excerpts
+      env:
+        CIRRUS_CLONE_SUBMODULES: true
+      script: ./script/tool_runner.sh update-excerpts --fail-on-change
     ### Web tasks ###
     - name: web-build_all_plugins
       env:
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..1d3bb5d
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "site-shared"]
+	path = site-shared
+	url = https://github.com/dart-lang/site-shared
diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md
index b864432..a998669 100644
--- a/packages/camera/camera/CHANGELOG.md
+++ b/packages/camera/camera/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 0.9.4+21
+
+* Fixes README code samples.
+
 ## 0.9.4+20
 
 * Fixes an issue with the orientation of videos recorded in landscape on Android.
diff --git a/packages/camera/camera/README.md b/packages/camera/camera/README.md
index a313c3a..a1c60a0 100644
--- a/packages/camera/camera/README.md
+++ b/packages/camera/camera/README.md
@@ -1,5 +1,7 @@
 # Camera Plugin
 
+<?code-excerpt path-base="excerpts/packages/camera_example"?>
+
 [![pub package](https://img.shields.io/pub/v/camera.svg)](https://pub.dev/packages/camera)
 
 A Flutter plugin for iOS, Android and Web allowing access to the device cameras.
@@ -59,33 +61,35 @@
 
 As of version [0.5.0](https://github.com/flutter/plugins/blob/master/packages/camera/CHANGELOG.md#050) of the camera plugin, lifecycle changes are no longer handled by the plugin. This means developers are now responsible to control camera resources when the lifecycle state is updated. Failure to do so might lead to unexpected behavior (for example as described in issue [#39109](https://github.com/flutter/flutter/issues/39109)). Handling lifecycle changes can be done by overriding the `didChangeAppLifecycleState` method like so:
 
+<?code-excerpt "main.dart (AppLifecycle)"?>
 ```dart
-  @override
-  void didChangeAppLifecycleState(AppLifecycleState state) {
-    // App state changed before we got the chance to initialize.
-    if (controller == null || !controller.value.isInitialized) {
-      return;
-    }
-    if (state == AppLifecycleState.inactive) {
-      controller?.dispose();
-    } else if (state == AppLifecycleState.resumed) {
-      if (controller != null) {
-        onNewCameraSelected(controller.description);
-      }
-    }
+@override
+void didChangeAppLifecycleState(AppLifecycleState state) {
+  final CameraController? cameraController = controller;
+
+  // App state changed before we got the chance to initialize.
+  if (cameraController == null || !cameraController.value.isInitialized) {
+    return;
   }
+
+  if (state == AppLifecycleState.inactive) {
+    cameraController.dispose();
+  } else if (state == AppLifecycleState.resumed) {
+    onNewCameraSelected(cameraController.description);
+  }
+}
 ```
 
 ### Example
 
 Here is a small example flutter app displaying a full screen camera preview.
 
+<?code-excerpt "readme_full_example.dart (FullAppExample)"?>
 ```dart
-import 'dart:async';
-import 'package:flutter/material.dart';
 import 'package:camera/camera.dart';
+import 'package:flutter/material.dart';
 
-List<CameraDescription> cameras;
+late List<CameraDescription> cameras;
 
 Future<void> main() async {
   WidgetsFlutterBinding.ensureInitialized();
@@ -100,7 +104,7 @@
 }
 
 class _CameraAppState extends State<CameraApp> {
-  CameraController controller;
+  late CameraController controller;
 
   @override
   void initState() {
@@ -116,7 +120,7 @@
 
   @override
   void dispose() {
-    controller?.dispose();
+    controller.dispose();
     super.dispose();
   }
 
@@ -130,7 +134,6 @@
     );
   }
 }
-
 ```
 
 For a more elaborate usage example see [here](https://github.com/flutter/plugins/tree/main/packages/camera/camera/example).
diff --git a/packages/camera/camera/example/build.excerpt.yaml b/packages/camera/camera/example/build.excerpt.yaml
new file mode 100644
index 0000000..e317efa
--- /dev/null
+++ b/packages/camera/camera/example/build.excerpt.yaml
@@ -0,0 +1,15 @@
+targets:
+  $default:
+    sources:
+      include:
+        - lib/**
+        # Some default includes that aren't really used here but will prevent
+        # false-negative warnings:
+        - $package$
+        - lib/$lib$
+      exclude:
+        - '**/.*/**'
+        - '**/build/**'
+    builders:
+      code_excerpter|code_excerpter:
+        enabled: true
diff --git a/packages/camera/camera/example/lib/main.dart b/packages/camera/camera/example/lib/main.dart
index f9f1378..aabbe24 100644
--- a/packages/camera/camera/example/lib/main.dart
+++ b/packages/camera/camera/example/lib/main.dart
@@ -106,6 +106,7 @@
     super.dispose();
   }
 
+  // #docregion AppLifecycle
   @override
   void didChangeAppLifecycleState(AppLifecycleState state) {
     final CameraController? cameraController = controller;
@@ -121,6 +122,7 @@
       onNewCameraSelected(cameraController.description);
     }
   }
+  // #enddocregion AppLifecycle
 
   @override
   Widget build(BuildContext context) {
diff --git a/packages/camera/camera/example/lib/readme_full_example.dart b/packages/camera/camera/example/lib/readme_full_example.dart
new file mode 100644
index 0000000..b25e637
--- /dev/null
+++ b/packages/camera/camera/example/lib/readme_full_example.dart
@@ -0,0 +1,56 @@
+// Copyright 2013 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.
+
+// ignore_for_file: public_member_api_docs
+
+// #docregion FullAppExample
+import 'package:camera/camera.dart';
+import 'package:flutter/material.dart';
+
+late List<CameraDescription> cameras;
+
+Future<void> main() async {
+  WidgetsFlutterBinding.ensureInitialized();
+
+  cameras = await availableCameras();
+  runApp(CameraApp());
+}
+
+class CameraApp extends StatefulWidget {
+  @override
+  _CameraAppState createState() => _CameraAppState();
+}
+
+class _CameraAppState extends State<CameraApp> {
+  late CameraController controller;
+
+  @override
+  void initState() {
+    super.initState();
+    controller = CameraController(cameras[0], ResolutionPreset.max);
+    controller.initialize().then((_) {
+      if (!mounted) {
+        return;
+      }
+      setState(() {});
+    });
+  }
+
+  @override
+  void dispose() {
+    controller.dispose();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    if (!controller.value.isInitialized) {
+      return Container();
+    }
+    return MaterialApp(
+      home: CameraPreview(controller),
+    );
+  }
+}
+// #enddocregion FullAppExample
diff --git a/packages/camera/camera/example/pubspec.yaml b/packages/camera/camera/example/pubspec.yaml
index 1700074..af4d078 100644
--- a/packages/camera/camera/example/pubspec.yaml
+++ b/packages/camera/camera/example/pubspec.yaml
@@ -20,6 +20,7 @@
   video_player: ^2.1.4
 
 dev_dependencies:
+  build_runner: ^2.1.10
   flutter_driver:
     sdk: flutter
   flutter_test:
diff --git a/packages/camera/camera/pubspec.yaml b/packages/camera/camera/pubspec.yaml
index feb83f9..f627770 100644
--- a/packages/camera/camera/pubspec.yaml
+++ b/packages/camera/camera/pubspec.yaml
@@ -4,7 +4,7 @@
   Dart.
 repository: https://github.com/flutter/plugins/tree/main/packages/camera/camera
 issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22
-version: 0.9.4+20
+version: 0.9.4+21
 
 environment:
   sdk: ">=2.14.0 <3.0.0"
diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md
index bfc0caf..82a3115 100644
--- a/script/tool/CHANGELOG.md
+++ b/script/tool/CHANGELOG.md
@@ -1,5 +1,8 @@
-## NEXT
+## 0.8.3
 
+- Adds a new `update-excerpts` command to maintain README files using the
+  `code-excerpter` package from flutter/site-shared.
+- `license-check` now ignores submodules.
 - Allows `make-deps-path-based` to skip packages it has alredy rewritten, so
   that running multiple times won't fail after the first time.
 
diff --git a/script/tool/README.md b/script/tool/README.md
index 05be917..d52ee08 100644
--- a/script/tool/README.md
+++ b/script/tool/README.md
@@ -107,6 +107,17 @@
 dart run ./script/tool/bin/flutter_plugin_tools.dart native-test --macos --packages plugin_name
 ```
 
+### Update README.md from Example Sources
+
+`update-excerpts` requires sources that are in a submodule. If you didn't clone
+with submodules, you will need to `git submodule update --init --recursive`
+before running this command.
+
+```sh
+cd <repository root>
+dart run ./script/tool/bin/flutter_plugin_tools.dart update-excerpts --packages plugin_name
+```
+
 ### Publish a Release
 
 **Releases are automated for `flutter/plugins` and `flutter/packages`.**
diff --git a/script/tool/lib/src/license_check_command.dart b/script/tool/lib/src/license_check_command.dart
index d2c129f..87e4c8b 100644
--- a/script/tool/lib/src/license_check_command.dart
+++ b/script/tool/lib/src/license_check_command.dart
@@ -3,7 +3,9 @@
 // found in the LICENSE file.
 
 import 'package:file/file.dart';
+import 'package:git/git.dart';
 import 'package:path/path.dart' as p;
+import 'package:platform/platform.dart';
 
 import 'common/core.dart';
 import 'common/plugin_command.dart';
@@ -105,7 +107,9 @@
 /// Validates that code files have copyright and license blocks.
 class LicenseCheckCommand extends PluginCommand {
   /// Creates a new license check command for [packagesDir].
-  LicenseCheckCommand(Directory packagesDir) : super(packagesDir);
+  LicenseCheckCommand(Directory packagesDir,
+      {Platform platform = const LocalPlatform(), GitDir? gitDir})
+      : super(packagesDir, platform: platform, gitDir: gitDir);
 
   @override
   final String name = 'license-check';
@@ -116,7 +120,14 @@
 
   @override
   Future<void> run() async {
-    final Iterable<File> allFiles = await _getAllFiles();
+    // Create a set of absolute paths to submodule directories, with trailing
+    // separator, to do prefix matching with to test directory inclusion.
+    final Iterable<String> submodulePaths = (await _getSubmoduleDirectories())
+        .map(
+            (Directory dir) => '${dir.absolute.path}${platform.pathSeparator}');
+
+    final Iterable<File> allFiles = (await _getAllFiles()).where(
+        (File file) => !submodulePaths.any(file.absolute.path.startsWith));
 
     final Iterable<File> codeFiles = allFiles.where((File file) =>
         _codeFileExtensions.contains(p.extension(file.path)) &&
@@ -275,6 +286,24 @@
       .where((FileSystemEntity entity) => entity is File)
       .map((FileSystemEntity file) => file as File)
       .toList();
+
+  // Returns the directories containing mapped submodules, if any.
+  Future<Iterable<Directory>> _getSubmoduleDirectories() async {
+    final List<Directory> submodulePaths = <Directory>[];
+    final Directory repoRoot =
+        packagesDir.fileSystem.directory((await gitDir).path);
+    final File submoduleSpec = repoRoot.childFile('.gitmodules');
+    if (submoduleSpec.existsSync()) {
+      final RegExp pathLine = RegExp(r'path\s*=\s*(.*)');
+      for (final String line in submoduleSpec.readAsLinesSync()) {
+        final RegExpMatch? match = pathLine.firstMatch(line);
+        if (match != null) {
+          submodulePaths.add(repoRoot.childDirectory(match.group(1)!.trim()));
+        }
+      }
+    }
+    return submodulePaths;
+  }
 }
 
 enum _LicenseFailureType { incorrectFirstParty, unknownThirdParty }
diff --git a/script/tool/lib/src/main.dart b/script/tool/lib/src/main.dart
index aa1cf30..9c572ee 100644
--- a/script/tool/lib/src/main.dart
+++ b/script/tool/lib/src/main.dart
@@ -28,6 +28,7 @@
 import 'pubspec_check_command.dart';
 import 'readme_check_command.dart';
 import 'test_command.dart';
+import 'update_excerpts_command.dart';
 import 'version_check_command.dart';
 import 'xcode_analyze_command.dart';
 
@@ -68,6 +69,7 @@
     ..addCommand(PubspecCheckCommand(packagesDir))
     ..addCommand(ReadmeCheckCommand(packagesDir))
     ..addCommand(TestCommand(packagesDir))
+    ..addCommand(UpdateExcerptsCommand(packagesDir))
     ..addCommand(VersionCheckCommand(packagesDir))
     ..addCommand(XcodeAnalyzeCommand(packagesDir));
 
diff --git a/script/tool/lib/src/update_excerpts_command.dart b/script/tool/lib/src/update_excerpts_command.dart
new file mode 100644
index 0000000..320a3c5
--- /dev/null
+++ b/script/tool/lib/src/update_excerpts_command.dart
@@ -0,0 +1,225 @@
+// Copyright 2013 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:io' as io;
+
+import 'package:file/file.dart';
+import 'package:flutter_plugin_tools/src/common/core.dart';
+import 'package:git/git.dart';
+import 'package:platform/platform.dart';
+import 'package:yaml/yaml.dart';
+import 'package:yaml_edit/yaml_edit.dart';
+
+import 'common/package_looping_command.dart';
+import 'common/process_runner.dart';
+import 'common/repository_package.dart';
+
+/// A command to update README code excerpts from code files.
+class UpdateExcerptsCommand extends PackageLoopingCommand {
+  /// Creates a excerpt updater command instance.
+  UpdateExcerptsCommand(
+    Directory packagesDir, {
+    ProcessRunner processRunner = const ProcessRunner(),
+    Platform platform = const LocalPlatform(),
+    GitDir? gitDir,
+  }) : super(
+          packagesDir,
+          processRunner: processRunner,
+          platform: platform,
+          gitDir: gitDir,
+        ) {
+    argParser.addFlag(_failOnChangeFlag, hide: true);
+  }
+
+  static const String _failOnChangeFlag = 'fail-on-change';
+
+  static const String _buildRunnerConfigName = 'excerpt';
+  // The name of the build_runner configuration file that will be in an example
+  // directory if the package is set up to use `code-excerpt`.
+  static const String _buildRunnerConfigFile =
+      'build.$_buildRunnerConfigName.yaml';
+
+  // The relative directory path to put the extracted excerpt yaml files.
+  static const String _excerptOutputDir = 'excerpts';
+
+  // The filename to store the pre-modification copy of the pubspec.
+  static const String _originalPubspecFilename =
+      'pubspec.plugin_tools_original.yaml';
+
+  @override
+  final String name = 'update-excerpts';
+
+  @override
+  final String description = 'Updates code excerpts in README.md files, based '
+      'on code from code files, via code-excerpt';
+
+  @override
+  Future<PackageResult> runForPackage(RepositoryPackage package) async {
+    final Iterable<RepositoryPackage> configuredExamples = package
+        .getExamples()
+        .where((RepositoryPackage example) =>
+            example.directory.childFile(_buildRunnerConfigFile).existsSync());
+
+    if (configuredExamples.isEmpty) {
+      return PackageResult.skip(
+          'No $_buildRunnerConfigFile found in example(s).');
+    }
+
+    final Directory repoRoot =
+        packagesDir.fileSystem.directory((await gitDir).path);
+
+    for (final RepositoryPackage example in configuredExamples) {
+      _addSubmoduleDependencies(example, repoRoot: repoRoot);
+
+      try {
+        // Ensure that dependencies are available.
+        final int pubGetExitCode = await processRunner.runAndStream(
+            'dart', <String>['pub', 'get'],
+            workingDir: example.directory);
+        if (pubGetExitCode != 0) {
+          return PackageResult.fail(
+              <String>['Unable to get script dependencies']);
+        }
+
+        // Update the excerpts.
+        if (!await _extractSnippets(example)) {
+          return PackageResult.fail(<String>['Unable to extract excerpts']);
+        }
+        if (!await _injectSnippets(example, targetPackage: package)) {
+          return PackageResult.fail(<String>['Unable to inject excerpts']);
+        }
+      } finally {
+        // Clean up the pubspec changes and extracted excerpts directory.
+        _undoPubspecChanges(example);
+        final Directory excerptDirectory =
+            example.directory.childDirectory(_excerptOutputDir);
+        if (excerptDirectory.existsSync()) {
+          excerptDirectory.deleteSync(recursive: true);
+        }
+      }
+    }
+
+    if (getBoolArg(_failOnChangeFlag)) {
+      final String? stateError = await _validateRepositoryState();
+      if (stateError != null) {
+        printError('README.md is out of sync with its source excerpts.\n\n'
+            'If you edited code in README.md directly, you should instead edit '
+            'the example source files. If you edited source files, run the '
+            'repository tooling\'s "$name" command on this package, and update '
+            'your PR with the resulting changes.');
+        return PackageResult.fail(<String>[stateError]);
+      }
+    }
+
+    return PackageResult.success();
+  }
+
+  /// Runs the extraction step to create the excerpt files for the given
+  /// example, returning true on success.
+  Future<bool> _extractSnippets(RepositoryPackage example) async {
+    final int exitCode = await processRunner.runAndStream(
+        'dart',
+        <String>[
+          'run',
+          'build_runner',
+          'build',
+          '--config',
+          _buildRunnerConfigName,
+          '--output',
+          _excerptOutputDir,
+          '--delete-conflicting-outputs',
+        ],
+        workingDir: example.directory);
+    return exitCode == 0;
+  }
+
+  /// Runs the injection step to update [targetPackage]'s README with the latest
+  /// excerpts from [example], returning true on success.
+  Future<bool> _injectSnippets(
+    RepositoryPackage example, {
+    required RepositoryPackage targetPackage,
+  }) async {
+    final String relativeReadmePath =
+        getRelativePosixPath(targetPackage.readmeFile, from: example.directory);
+    final int exitCode = await processRunner.runAndStream(
+        'dart',
+        <String>[
+          'run',
+          'code_excerpt_updater',
+          '--write-in-place',
+          '--yaml',
+          '--no-escape-ng-interpolation',
+          relativeReadmePath,
+        ],
+        workingDir: example.directory);
+    return exitCode == 0;
+  }
+
+  /// Adds `code_excerpter` and `code_excerpt_updater` to [package]'s
+  /// `dev_dependencies` using path-based references to the submodule copies.
+  ///
+  /// This is done on the fly rather than being checked in so that:
+  /// - Just building examples don't require everyone to check out submodules.
+  /// - Examples can be analyzed/built even on versions of Flutter that these
+  ///   submodules do not support.
+  void _addSubmoduleDependencies(RepositoryPackage package,
+      {required Directory repoRoot}) {
+    final String pubspecContents = package.pubspecFile.readAsStringSync();
+    // Save aside a copy of the current pubspec state. This allows restoration
+    // to the previous state regardless of its git status at the time the script
+    // ran.
+    package.directory
+        .childFile(_originalPubspecFilename)
+        .writeAsStringSync(pubspecContents);
+
+    // Update the actual pubspec.
+    final YamlEditor editablePubspec = YamlEditor(pubspecContents);
+    const String devDependenciesKey = 'dev_dependencies';
+    final YamlNode root = editablePubspec.parseAt(<String>[]);
+    // Ensure that there's a `dev_dependencies` entry to update.
+    if ((root as YamlMap)[devDependenciesKey] == null) {
+      editablePubspec.update(<String>['dev_dependencies'], YamlMap());
+    }
+    final Set<String> submoduleDependencies = <String>{
+      'code_excerpter',
+      'code_excerpt_updater',
+    };
+    final String relativeRootPath =
+        getRelativePosixPath(repoRoot, from: package.directory);
+    for (final String dependency in submoduleDependencies) {
+      editablePubspec.update(<String>[
+        devDependenciesKey,
+        dependency
+      ], <String, String>{
+        'path': '$relativeRootPath/site-shared/packages/$dependency'
+      });
+    }
+    package.pubspecFile.writeAsStringSync(editablePubspec.toString());
+  }
+
+  /// Restores the version of the pubspec that was present before running
+  /// [_addSubmoduleDependencies].
+  void _undoPubspecChanges(RepositoryPackage package) {
+    package.directory
+        .childFile(_originalPubspecFilename)
+        .renameSync(package.pubspecFile.path);
+  }
+
+  /// Checks the git state, returning an error string unless nothing has
+  /// changed.
+  Future<String?> _validateRepositoryState() async {
+    final io.ProcessResult modifiedFiles = await processRunner.run(
+      'git',
+      <String>['ls-files', '--modified'],
+      workingDir: packagesDir,
+      logOnError: true,
+    );
+    if (modifiedFiles.exitCode != 0) {
+      return 'Unable to determine local file state';
+    }
+
+    final String stdout = modifiedFiles.stdout as String;
+    return stdout.trim().isEmpty ? null : 'Snippets are out of sync';
+  }
+}
diff --git a/script/tool/pubspec.yaml b/script/tool/pubspec.yaml
index 14b22a1..9f9910f 100644
--- a/script/tool/pubspec.yaml
+++ b/script/tool/pubspec.yaml
@@ -1,7 +1,7 @@
 name: flutter_plugin_tools
 description: Productivity utils for flutter/plugins and flutter/packages
 repository: https://github.com/flutter/plugins/tree/main/script/tool
-version: 0.8.2+1
+version: 0.8.3
 
 dependencies:
   args: ^2.1.0
@@ -21,6 +21,7 @@
   test: ^1.17.3
   uuid: ^3.0.4
   yaml: ^3.1.0
+  yaml_edit: ^2.0.2
 
 dev_dependencies:
   build_runner: ^2.0.3
diff --git a/script/tool/test/format_command_test.dart b/script/tool/test/format_command_test.dart
index 2890c52..6c10a7d 100644
--- a/script/tool/test/format_command_test.dart
+++ b/script/tool/test/format_command_test.dart
@@ -448,7 +448,7 @@
         ]));
   });
 
-  test('fails if files are changed with --file-on-change', () async {
+  test('fails if files are changed with --fail-on-change', () async {
     const List<String> files = <String>[
       'linux/foo_plugin.cc',
       'macos/Classes/Foo.h',
diff --git a/script/tool/test/license_check_command_test.dart b/script/tool/test/license_check_command_test.dart
index e97274a..efaf969 100644
--- a/script/tool/test/license_check_command_test.dart
+++ b/script/tool/test/license_check_command_test.dart
@@ -7,24 +7,35 @@
 import 'package:file/memory.dart';
 import 'package:flutter_plugin_tools/src/common/core.dart';
 import 'package:flutter_plugin_tools/src/license_check_command.dart';
+import 'package:mockito/mockito.dart';
+import 'package:platform/platform.dart';
 import 'package:test/test.dart';
 
+import 'common/plugin_command_test.mocks.dart';
+import 'mocks.dart';
 import 'util.dart';
 
 void main() {
-  group('$LicenseCheckCommand', () {
+  group('LicenseCheckCommand', () {
     late CommandRunner<void> runner;
     late FileSystem fileSystem;
+    late Platform platform;
     late Directory root;
 
     setUp(() {
       fileSystem = MemoryFileSystem();
+      platform = MockPlatformWithSeparator();
       final Directory packagesDir =
           fileSystem.currentDirectory.childDirectory('packages');
       root = packagesDir.parent;
 
+      final MockGitDir gitDir = MockGitDir();
+      when(gitDir.path).thenReturn(packagesDir.parent.path);
+
       final LicenseCheckCommand command = LicenseCheckCommand(
         packagesDir,
+        platform: platform,
+        gitDir: gitDir,
       );
       runner =
           CommandRunner<void>('license_test', 'Test for $LicenseCheckCommand');
@@ -123,6 +134,33 @@
       }
     });
 
+    test('ignores submodules', () async {
+      const String submoduleName = 'a_submodule';
+
+      final File submoduleSpec = root.childFile('.gitmodules');
+      submoduleSpec.writeAsStringSync('''
+[submodule "$submoduleName"]
+  path = $submoduleName
+  url = https://github.com/foo/$submoduleName
+''');
+
+      const List<String> submoduleFiles = <String>[
+        '$submoduleName/foo.dart',
+        '$submoduleName/a/b/bar.dart',
+        '$submoduleName/LICENSE',
+      ];
+      for (final String filePath in submoduleFiles) {
+        root.childFile(filePath).createSync(recursive: true);
+      }
+
+      final List<String> output =
+          await runCapturingPrint(runner, <String>['license-check']);
+
+      for (final String filePath in submoduleFiles) {
+        expect(output, isNot(contains('Checking $filePath')));
+      }
+    });
+
     test('passes if all checked files have license blocks', () async {
       final File checked = root.childFile('checked.cc');
       checked.createSync();
@@ -509,6 +547,11 @@
   });
 }
 
+class MockPlatformWithSeparator extends MockPlatform {
+  @override
+  String get pathSeparator => isWindows ? r'\' : '/';
+}
+
 const String _correctLicenseFileText = '''
 Copyright 2013 The Flutter Authors. All rights reserved.
 
diff --git a/script/tool/test/update_excerpts_command_test.dart b/script/tool/test/update_excerpts_command_test.dart
new file mode 100644
index 0000000..30189cf
--- /dev/null
+++ b/script/tool/test/update_excerpts_command_test.dart
@@ -0,0 +1,284 @@
+// Copyright 2013 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:io' as io;
+
+import 'package:args/command_runner.dart';
+import 'package:file/file.dart';
+import 'package:file/memory.dart';
+import 'package:flutter_plugin_tools/src/common/core.dart';
+import 'package:flutter_plugin_tools/src/common/repository_package.dart';
+import 'package:flutter_plugin_tools/src/update_excerpts_command.dart';
+import 'package:mockito/mockito.dart';
+import 'package:test/test.dart';
+
+import 'common/plugin_command_test.mocks.dart';
+import 'mocks.dart';
+import 'util.dart';
+
+void main() {
+  late FileSystem fileSystem;
+  late Directory packagesDir;
+  late RecordingProcessRunner processRunner;
+  late CommandRunner<void> runner;
+
+  setUp(() {
+    fileSystem = MemoryFileSystem();
+    packagesDir = createPackagesDirectory(fileSystem: fileSystem);
+    final MockGitDir gitDir = MockGitDir();
+    when(gitDir.path).thenReturn(packagesDir.parent.path);
+    processRunner = RecordingProcessRunner();
+    final UpdateExcerptsCommand command = UpdateExcerptsCommand(
+      packagesDir,
+      processRunner: processRunner,
+      platform: MockPlatform(),
+      gitDir: gitDir,
+    );
+
+    runner = CommandRunner<void>(
+        'update_excerpts_command', 'Test for update_excerpts_command');
+    runner.addCommand(command);
+  });
+
+  test('runs pub get before running scripts', () async {
+    final Directory package = createFakePlugin('a_package', packagesDir,
+        extraFiles: <String>['example/build.excerpt.yaml']);
+    final Directory example = package.childDirectory('example');
+
+    await runCapturingPrint(runner, <String>['update-excerpts']);
+
+    expect(
+        processRunner.recordedCalls,
+        containsAll(<ProcessCall>[
+          ProcessCall('dart', const <String>['pub', 'get'], example.path),
+          ProcessCall(
+              'dart',
+              const <String>[
+                'run',
+                'build_runner',
+                'build',
+                '--config',
+                'excerpt',
+                '--output',
+                'excerpts',
+                '--delete-conflicting-outputs',
+              ],
+              example.path),
+        ]));
+  });
+
+  test('runs when config is present', () async {
+    final Directory package = createFakePlugin('a_package', packagesDir,
+        extraFiles: <String>['example/build.excerpt.yaml']);
+    final Directory example = package.childDirectory('example');
+
+    final List<String> output =
+        await runCapturingPrint(runner, <String>['update-excerpts']);
+
+    expect(
+        processRunner.recordedCalls,
+        containsAll(<ProcessCall>[
+          ProcessCall(
+              'dart',
+              const <String>[
+                'run',
+                'build_runner',
+                'build',
+                '--config',
+                'excerpt',
+                '--output',
+                'excerpts',
+                '--delete-conflicting-outputs',
+              ],
+              example.path),
+          ProcessCall(
+              'dart',
+              const <String>[
+                'run',
+                'code_excerpt_updater',
+                '--write-in-place',
+                '--yaml',
+                '--no-escape-ng-interpolation',
+                '../README.md',
+              ],
+              example.path),
+        ]));
+
+    expect(
+        output,
+        containsAllInOrder(<Matcher>[
+          contains('Ran for 1 package(s)'),
+        ]));
+  });
+
+  test('skips when no config is present', () async {
+    createFakePlugin('a_package', packagesDir);
+
+    final List<String> output =
+        await runCapturingPrint(runner, <String>['update-excerpts']);
+
+    expect(processRunner.recordedCalls, isEmpty);
+
+    expect(
+        output,
+        containsAllInOrder(<Matcher>[
+          contains('Skipped 1 package(s)'),
+        ]));
+  });
+
+  test('restores pubspec even if running the script fails', () async {
+    final Directory package = createFakePlugin('a_package', packagesDir,
+        extraFiles: <String>['example/build.excerpt.yaml']);
+
+    processRunner.mockProcessesForExecutable['dart'] = <io.Process>[
+      MockProcess(exitCode: 1), // dart pub get
+    ];
+
+    Error? commandError;
+    final List<String> output = await runCapturingPrint(
+        runner, <String>['update-excerpts'], errorHandler: (Error e) {
+      commandError = e;
+    });
+
+    // Check that it's definitely a failure in a step between making the changes
+    // and restoring the original.
+    expect(commandError, isA<ToolExit>());
+    expect(
+        output,
+        containsAllInOrder(<Matcher>[
+          contains('The following packages had errors:'),
+          contains('a_package:\n'
+              '    Unable to get script dependencies')
+        ]));
+
+    final String examplePubspecContent = RepositoryPackage(package)
+        .getExamples()
+        .first
+        .pubspecFile
+        .readAsStringSync();
+    expect(examplePubspecContent, isNot(contains('code_excerpter')));
+    expect(examplePubspecContent, isNot(contains('code_excerpt_updater')));
+  });
+
+  test('fails if pub get fails', () async {
+    createFakePlugin('a_package', packagesDir,
+        extraFiles: <String>['example/build.excerpt.yaml']);
+
+    processRunner.mockProcessesForExecutable['dart'] = <io.Process>[
+      MockProcess(exitCode: 1), // dart pub get
+    ];
+
+    Error? commandError;
+    final List<String> output = await runCapturingPrint(
+        runner, <String>['update-excerpts'], errorHandler: (Error e) {
+      commandError = e;
+    });
+
+    expect(commandError, isA<ToolExit>());
+    expect(
+        output,
+        containsAllInOrder(<Matcher>[
+          contains('The following packages had errors:'),
+          contains('a_package:\n'
+              '    Unable to get script dependencies')
+        ]));
+  });
+
+  test('fails if extraction fails', () async {
+    createFakePlugin('a_package', packagesDir,
+        extraFiles: <String>['example/build.excerpt.yaml']);
+
+    processRunner.mockProcessesForExecutable['dart'] = <io.Process>[
+      MockProcess(exitCode: 0), // dart pub get
+      MockProcess(exitCode: 1), // dart run build_runner ...
+    ];
+
+    Error? commandError;
+    final List<String> output = await runCapturingPrint(
+        runner, <String>['update-excerpts'], errorHandler: (Error e) {
+      commandError = e;
+    });
+
+    expect(commandError, isA<ToolExit>());
+    expect(
+        output,
+        containsAllInOrder(<Matcher>[
+          contains('The following packages had errors:'),
+          contains('a_package:\n'
+              '    Unable to extract excerpts')
+        ]));
+  });
+
+  test('fails if injection fails', () async {
+    createFakePlugin('a_package', packagesDir,
+        extraFiles: <String>['example/build.excerpt.yaml']);
+
+    processRunner.mockProcessesForExecutable['dart'] = <io.Process>[
+      MockProcess(exitCode: 0), // dart pub get
+      MockProcess(exitCode: 0), // dart run build_runner ...
+      MockProcess(exitCode: 1), // dart run code_excerpt_updater ...
+    ];
+
+    Error? commandError;
+    final List<String> output = await runCapturingPrint(
+        runner, <String>['update-excerpts'], errorHandler: (Error e) {
+      commandError = e;
+    });
+
+    expect(commandError, isA<ToolExit>());
+    expect(
+        output,
+        containsAllInOrder(<Matcher>[
+          contains('The following packages had errors:'),
+          contains('a_package:\n'
+              '    Unable to inject excerpts')
+        ]));
+  });
+
+  test('fails if files are changed with --fail-on-change', () async {
+    createFakePlugin('a_plugin', packagesDir,
+        extraFiles: <String>['example/build.excerpt.yaml']);
+
+    const String changedFilePath = 'packages/a_plugin/linux/foo_plugin.cc';
+    processRunner.mockProcessesForExecutable['git'] = <io.Process>[
+      MockProcess(stdout: changedFilePath),
+    ];
+
+    Error? commandError;
+    final List<String> output = await runCapturingPrint(
+        runner, <String>['update-excerpts', '--fail-on-change'],
+        errorHandler: (Error e) {
+      commandError = e;
+    });
+
+    expect(commandError, isA<ToolExit>());
+    expect(
+        output,
+        containsAllInOrder(<Matcher>[
+          contains('README.md is out of sync with its source excerpts'),
+        ]));
+  });
+
+  test('fails if git ls-files fails', () async {
+    createFakePlugin('a_plugin', packagesDir,
+        extraFiles: <String>['example/build.excerpt.yaml']);
+
+    processRunner.mockProcessesForExecutable['git'] = <io.Process>[
+      MockProcess(exitCode: 1)
+    ];
+    Error? commandError;
+    final List<String> output = await runCapturingPrint(
+        runner, <String>['update-excerpts', '--fail-on-change'],
+        errorHandler: (Error e) {
+      commandError = e;
+    });
+
+    expect(commandError, isA<ToolExit>());
+    expect(
+        output,
+        containsAllInOrder(<Matcher>[
+          contains('Unable to determine local file state'),
+        ]));
+  });
+}
diff --git a/site-shared b/site-shared
new file mode 160000
index 0000000..142de13
--- /dev/null
+++ b/site-shared
@@ -0,0 +1 @@
+Subproject commit 142de133477bdede1746f992e656c4b43c4c7442