Validate git is clean when publishing (#4373)

diff --git a/lib/src/git.dart b/lib/src/git.dart
index 8bbbb53..2f0603f 100644
--- a/lib/src/git.dart
+++ b/lib/src/git.dart
@@ -50,6 +50,37 @@
 /// Tests whether or not the git command-line app is available for use.
 bool get isInstalled => command != null;
 
+/// Splits the [output] of a git -z command at \0.
+///
+/// The first [skipPrefix] bytes of each substring will be ignored (useful for
+/// `git status -z`). If there are not enough bytes to skip, throws a
+/// [FormatException].
+List<Uint8List> splitZeroTerminated(Uint8List output, {int skipPrefix = 0}) {
+  final result = <Uint8List>[];
+  var start = 0;
+
+  for (var i = 0; i < output.length; i++) {
+    if (output[i] != 0) {
+      continue;
+    }
+    if (start + skipPrefix > i) {
+      throw FormatException('Substring too short for prefix at $start');
+    }
+    result.add(
+      Uint8List.sublistView(
+        output,
+        // The first 3 bytes are the modification status.
+        // Skip those.
+        start + skipPrefix,
+        i,
+      ),
+    );
+
+    start = i + 1;
+  }
+  return result;
+}
+
 /// Run a git process with [args] from [workingDir].
 ///
 /// Returns the stdout if it succeeded. Completes to ans exception if it failed.
diff --git a/lib/src/validator.dart b/lib/src/validator.dart
index fa204ec..80c41a0 100644
--- a/lib/src/validator.dart
+++ b/lib/src/validator.dart
@@ -25,6 +25,7 @@
 import 'validator/file_case.dart';
 import 'validator/flutter_constraint.dart';
 import 'validator/flutter_plugin_format.dart';
+import 'validator/git_status.dart';
 import 'validator/gitignore.dart';
 import 'validator/leak_detection.dart';
 import 'validator/license.dart';
@@ -143,6 +144,7 @@
       FileCaseValidator(),
       AnalyzeValidator(),
       GitignoreValidator(),
+      GitStatusValidator(),
       PubspecValidator(),
       LicenseValidator(),
       NameValidator(),
diff --git a/lib/src/validator/git_status.dart b/lib/src/validator/git_status.dart
new file mode 100644
index 0000000..c060925
--- /dev/null
+++ b/lib/src/validator/git_status.dart
@@ -0,0 +1,96 @@
+// Copyright (c) 2024, 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:async';
+import 'dart:convert';
+import 'dart:typed_data';
+
+import 'package:path/path.dart' as p;
+
+import '../git.dart' as git;
+import '../log.dart' as log;
+import '../utils.dart';
+import '../validator.dart';
+
+/// A validator that validates that no checked in files are modified in git.
+///
+/// Doesn't report on newly added files, as generated files might not be checked
+/// in to git.
+class GitStatusValidator extends Validator {
+  @override
+  Future<void> validate() async {
+    if (!package.inGitRepo) {
+      return;
+    }
+    final Uint8List output;
+    final String reporoot;
+    try {
+      final maybeReporoot = git.repoRoot(package.dir);
+      if (maybeReporoot == null) {
+        log.fine(
+          'Could not determine the repository root from ${package.dir}.',
+        );
+        // This validation is only a warning.
+        return;
+      }
+      reporoot = maybeReporoot;
+      output = git.runSyncBytes(
+        [
+          'status',
+          '-z', // Machine parsable
+          '--no-renames', // We don't care about renames.
+
+          '--untracked-files=no', // Don't show untracked files.
+        ],
+        workingDir: package.dir,
+      );
+    } on git.GitException catch (e) {
+      log.fine('Could not run `git status` files in repo (${e.message}).');
+      // This validation is only a warning.
+      // If git is not supported on the platform, we just continue silently.
+      return;
+    }
+    final List<String> modifiedFiles;
+    try {
+      modifiedFiles = git
+          .splitZeroTerminated(output, skipPrefix: 3)
+          .map((bytes) {
+            try {
+              final filename = utf8.decode(bytes);
+              final fullPath = p.join(reporoot, filename);
+              if (!files.any((f) => p.equals(fullPath, f))) {
+                // File is not in the published set - ignore.
+                return null;
+              }
+              return p.relative(fullPath);
+            } on FormatException catch (e) {
+              // Filename is not utf8 - ignore.
+              log.fine('Cannot decode file name: $e');
+              return null;
+            }
+          })
+          .nonNulls
+          .toList();
+    } on FormatException catch (e) {
+      // Malformed output from `git status`. Skip this validation.
+      log.fine('Malformed output from `git status -z`: $e');
+      return;
+    }
+    if (modifiedFiles.isNotEmpty) {
+      warnings.add('''
+${modifiedFiles.length} checked-in ${pluralize('file', modifiedFiles.length)} ${modifiedFiles.length == 1 ? 'is' : 'are'} modified in git.
+
+Usually you want to publish from a clean git state.
+
+Consider committing these files or reverting the changes.
+
+Modified files:
+
+${modifiedFiles.take(10).map(p.relative).join('\n')}
+${modifiedFiles.length > 10 ? '...\n' : ''}
+Run `git status` for more information.
+''');
+    }
+  }
+}
diff --git a/lib/src/validator/gitignore.dart b/lib/src/validator/gitignore.dart
index 630a1dd..5d0ffb7 100644
--- a/lib/src/validator/gitignore.dart
+++ b/lib/src/validator/gitignore.dart
@@ -44,16 +44,15 @@
         // --recurse-submodules we just continue silently.
         return;
       }
-      final checkedIntoGit = <String>[];
-      // Split at \0.
-      var start = 0;
-      for (var i = 0; i < output.length; i++) {
-        if (output[i] == 0) {
-          checkedIntoGit.add(
-            utf8.decode(Uint8List.sublistView(output, start, i)),
-          );
-          start = i + 1;
-        }
+
+      final List<String> checkedIntoGit;
+      try {
+        checkedIntoGit = git.splitZeroTerminated(output).map((b) {
+          return utf8.decode(b);
+        }).toList();
+      } on FormatException catch (e) {
+        log.fine('Failed decoding git output. Skipping validation. $e.');
+        return;
       }
       final root = git.repoRoot(package.dir) ?? package.dir;
       var beneath = p.posix.joinAll(
diff --git a/test/git_test.dart b/test/git_test.dart
new file mode 100644
index 0000000..3dae020
--- /dev/null
+++ b/test/git_test.dart
@@ -0,0 +1,48 @@
+// Copyright (c) 2024, 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:typed_data';
+
+import 'package:pub/src/git.dart';
+import 'package:test/test.dart';
+
+void main() {
+  test('splitZeroTerminated works', () {
+    expect(splitZeroTerminated(Uint8List.fromList([])), <Uint8List>[]);
+    expect(
+      splitZeroTerminated(Uint8List.fromList([0])),
+      <Uint8List>[Uint8List.fromList([])],
+    );
+
+    expect(splitZeroTerminated(Uint8List.fromList([1, 0, 1])), <Uint8List>[
+      Uint8List.fromList([1]),
+    ]);
+    expect(
+      splitZeroTerminated(Uint8List.fromList([2, 1, 0, 1, 0, 0])),
+      <Uint8List>[
+        Uint8List.fromList([2, 1]),
+        Uint8List.fromList([1]),
+        Uint8List.fromList([]),
+      ],
+    );
+    expect(
+      splitZeroTerminated(
+        Uint8List.fromList([2, 1, 0, 1, 0, 2, 3, 0]),
+        skipPrefix: 1,
+      ),
+      <Uint8List>[
+        Uint8List.fromList([1]),
+        Uint8List.fromList([]),
+        Uint8List.fromList([3]),
+      ],
+    );
+    expect(
+      () => splitZeroTerminated(
+        Uint8List.fromList([2, 1, 0, 1, 0, 0]),
+        skipPrefix: 1,
+      ),
+      throwsA(isA<FormatException>()),
+    );
+  });
+}
diff --git a/test/validator/git_status_test.dart b/test/validator/git_status_test.dart
new file mode 100644
index 0000000..f92653a
--- /dev/null
+++ b/test/validator/git_status_test.dart
@@ -0,0 +1,233 @@
+// Copyright (c) 2020, 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 'package:path/path.dart' as p;
+import 'package:pub/src/exit_codes.dart' as exit_codes;
+import 'package:test/test.dart';
+
+import '../descriptor.dart' as d;
+import '../test_pub.dart';
+
+Future<void> expectValidation(
+  Matcher error,
+  int exitCode, {
+  List<String> extraArgs = const [],
+  Map<String, String> environment = const {},
+  String? workingDirectory,
+}) async {
+  await runPub(
+    error: error,
+    args: ['publish', '--dry-run', ...extraArgs],
+    environment: environment,
+    workingDirectory: workingDirectory ?? d.path(appPath),
+    exitCode: exitCode,
+  );
+}
+
+void main() {
+  test(
+      'should consider a package valid '
+      'if it contains no modified files (but contains a newly created one)',
+      () async {
+    await d.git('myapp', [
+      ...d.validPackage().contents,
+      d.file('foo.txt', 'foo'),
+      d.file('.pubignore', 'bob.txt\n'),
+      d.file('bob.txt', 'bob'),
+    ]).create();
+
+    await d.dir('myapp', [
+      d.file('bar.txt', 'bar'), // Create untracked file.
+      d.file('bob.txt', 'bob2'), // Modify pub-ignored file.
+    ]).create();
+
+    await expectValidation(contains('Package has 0 warnings.'), 0);
+  });
+
+  test('Warns if files are modified', () async {
+    await d.git('myapp', [
+      ...d.validPackage().contents,
+      d.file('foo.txt', 'foo'),
+    ]).create();
+
+    await d.dir('myapp', [
+      d.file('foo.txt', 'foo2'),
+    ]).create();
+
+    await expectValidation(
+      allOf([
+        contains('Package has 1 warning.'),
+        contains(
+          '''
+* 1 checked-in file is modified in git.
+  
+  Usually you want to publish from a clean git state.
+  
+  Consider committing these files or reverting the changes.
+  
+  Modified files:
+  
+  foo.txt
+  
+  Run `git status` for more information.''',
+        ),
+      ]),
+      exit_codes.DATA,
+    );
+
+    // Stage but do not commit foo.txt. The warning should still be active.
+    await d.git('myapp').runGit(['add', 'foo.txt']);
+    await expectValidation(
+      allOf([
+        contains('Package has 1 warning.'),
+        contains('foo.txt'),
+      ]),
+      exit_codes.DATA,
+    );
+    await d.git('myapp').runGit(['commit', '-m', 'message']);
+
+    await d.dir('myapp', [
+      d.file('bar.txt', 'bar'), // Create untracked file.
+      d.file('bob.txt', 'bob2'), // Modify pub-ignored file.
+    ]).create();
+
+    // Stage untracked file, now the warning should be about that.
+    await d.git('myapp').runGit(['add', 'bar.txt']);
+
+    await expectValidation(
+      allOf([
+        contains('Package has 1 warning.'),
+        contains(
+          '''
+* 1 checked-in file is modified in git.
+  
+  Usually you want to publish from a clean git state.
+  
+  Consider committing these files or reverting the changes.
+  
+  Modified files:
+  
+  bar.txt
+  
+  Run `git status` for more information.''',
+        ),
+      ]),
+      exit_codes.DATA,
+    );
+  });
+
+  test('Works with non-ascii unicode characters in file name', () async {
+    await d.git('myapp', [
+      ...d.validPackage().contents,
+      d.file('non_ascii_и.txt', 'foo'),
+      d.file('non_ascii_и_ignored.txt', 'foo'),
+      d.file('.pubignore', 'non_ascii_и_ignored.txt'),
+    ]).create();
+    await d.dir('myapp', [
+      ...d.validPackage().contents,
+      d.file('non_ascii_и.txt', 'foo2'),
+      d.file('non_ascii_и_ignored.txt', 'foo2'),
+    ]).create();
+
+    await expectValidation(
+      allOf([
+        contains('Package has 1 warning.'),
+        contains(
+          '''
+* 1 checked-in file is modified in git.
+  
+  Usually you want to publish from a clean git state.
+  
+  Consider committing these files or reverting the changes.
+  
+  Modified files:
+  
+  non_ascii_и.txt
+  
+  Run `git status` for more information.''',
+        ),
+      ]),
+      exit_codes.DATA,
+    );
+  });
+
+  test('Works in workspace', () async {
+    await d.git(appPath, [
+      d.libPubspec(
+        'myapp',
+        '1.2.3',
+        extras: {
+          'workspace': ['a'],
+        },
+        sdk: '^3.5.0',
+      ),
+      d.dir('a', [
+        ...d.validPackage().contents,
+        d.file('non_ascii_и.txt', 'foo'),
+        d.file('non_ascii_и_ignored.txt', 'foo'),
+        d.file('.pubignore', 'non_ascii_и_ignored.txt'),
+      ]),
+    ]).create();
+
+    await d.dir(appPath, [
+      d.dir('a', [
+        d.file('non_ascii_и.txt', 'foo2'),
+        d.file('non_ascii_и_ignored.txt', 'foo2'),
+        d.file('.pubignore', 'non_ascii_и_ignored.txt'),
+      ]),
+    ]).create();
+
+    await expectValidation(
+      workingDirectory: p.join(
+        d.sandbox,
+        appPath,
+      ),
+      extraArgs: ['-C', 'a'],
+      allOf([
+        contains('Package has 1 warning.'),
+        contains(
+          '''
+* 1 checked-in file is modified in git.
+  
+  Usually you want to publish from a clean git state.
+  
+  Consider committing these files or reverting the changes.
+  
+  Modified files:
+  
+  a${p.separator}non_ascii_и.txt
+  
+  Run `git status` for more information.''',
+        ),
+      ]),
+      exit_codes.DATA,
+    );
+
+    await expectValidation(
+      workingDirectory: p.join(
+        d.sandbox,
+        appPath,
+        'a',
+      ),
+      allOf([
+        contains('Package has 1 warning.'),
+        contains(
+          '''
+* 1 checked-in file is modified in git.
+  
+  Usually you want to publish from a clean git state.
+  
+  Consider committing these files or reverting the changes.
+  
+  Modified files:
+  
+  non_ascii_и.txt
+  
+  Run `git status` for more information.''',
+        ),
+      ]),
+      exit_codes.DATA,
+    );
+  });
+}