Skip to content

Commit

Permalink
Validate git is clean when publishing (#4373)
Browse files Browse the repository at this point in the history
  • Loading branch information
sigurdm authored Sep 10, 2024
1 parent e9ad2bc commit bfbc6bc
Show file tree
Hide file tree
Showing 6 changed files with 419 additions and 10 deletions.
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}) {
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

0 comments on commit bfbc6bc

Please sign in to comment.