Skip to content

Validate git is clean when publishing #4373

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Sep 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions lib/src/git.dart
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,37 @@ class GitException implements ApplicationException {
/// 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}) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

splitZeroTerminated without the skipPrefix might have been more sane.

But this is also fine :D

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah - more sane - just I wanted to avoid creating too much garbage, and (as far as I can tell) utf8.decode cannot work on a partial buffer...

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.
Expand Down
2 changes: 2 additions & 0 deletions lib/src/validator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import 'validator/executable.dart';
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';
Expand Down Expand Up @@ -143,6 +144,7 @@ abstract class Validator {
FileCaseValidator(),
AnalyzeValidator(),
GitignoreValidator(),
GitStatusValidator(),
PubspecValidator(),
LicenseValidator(),
NameValidator(),
Expand Down
96 changes: 96 additions & 0 deletions lib/src/validator/git_status.dart
Original file line number Diff line number Diff line change
@@ -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.
''');
}
}
}
19 changes: 9 additions & 10 deletions lib/src/validator/gitignore.dart
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,15 @@ class GitignoreValidator extends Validator {
// --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(
Expand Down
48 changes: 48 additions & 0 deletions test/git_test.dart
Original file line number Diff line number Diff line change
@@ -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>()),
);
});
}
Loading