-
Notifications
You must be signed in to change notification settings - Fork 3.9k
[githooks] Add pre-push hook to prevent pushing evaluation test commits #12663
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
base: main
Are you sure you want to change the base?
Changes from all commits
b0ea594
32e56bb
bf6d55e
5413c73
77f8c68
776e479
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| #!/bin/bash | ||
| # Copyright 2013 The Flutter Authors | ||
| # Use of this source code is governed by a BSD-style license that can be | ||
| # found in the LICENSE file. | ||
| set -e | ||
|
|
||
| cd script/githooks | ||
| dart pub get | ||
| dart test |
|
reidbaker marked this conversation as resolved.
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| // Copyright 2013 The Flutter Authors | ||
| // Use of this source code is governed by a BSD-style license that can be | ||
| // found in the LICENSE file. | ||
|
|
||
| // ignore_for_file: avoid_print | ||
|
|
||
| import 'dart:io'; | ||
| import 'package:args/command_runner.dart'; | ||
|
Comment on lines
+7
to
+8
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| /// The author name used in evaluation test commits. | ||
| const String evalAuthorName = 'Eval Author'; | ||
|
reidbaker marked this conversation as resolved.
|
||
|
|
||
| /// The author email used in evaluation test commits. | ||
| const String evalAuthorEmail = 'eval-author@example.com'; | ||
|
|
||
| /// The command that implements the `pre-push` Git hook. | ||
| /// | ||
| /// It inspects the last 20 commits in git history to ensure no commits | ||
| /// were authored or committed using evaluation test credentials (`Eval Author` | ||
| /// or `eval-author@example.com`). | ||
| /// | ||
| /// Checking the last 20 commits keeps the hook simple and deterministic, | ||
| /// avoiding complex diff calculations against arbitrary remote branches or | ||
| /// tracking whether a branch has an open pull request under review. | ||
| class PrePushCommand extends Command<bool> { | ||
| /// Creates a [PrePushCommand]. | ||
| PrePushCommand({ | ||
| Future<ProcessResult> Function( | ||
| String executable, | ||
| List<String> arguments, { | ||
| String? workingDirectory, | ||
| })? | ||
| processRunner, | ||
| }) : processRunner = processRunner ?? Process.run; | ||
|
|
||
| /// The process runner injected for testing. | ||
| final Future<ProcessResult> Function( | ||
| String executable, | ||
| List<String> arguments, { | ||
| String? workingDirectory, | ||
| }) | ||
| processRunner; | ||
|
Comment on lines
+27
to
+42
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add an optional PrePushCommand({
Future<ProcessResult> Function(
String executable,
List<String> arguments,
{String? workingDirectory},
)?
processRunner,
Stream<List<int>>? stdIn,
}) : processRunner = processRunner ?? Process.run,
stdIn = stdIn ?? stdin;
/// The process runner injected for testing.
final Future<ProcessResult> Function(
String executable,
List<String> arguments,
{String? workingDirectory},
)
processRunner;
/// The standard input stream injected for testing.
final Stream<List<int>> stdIn; |
||
|
|
||
| @override | ||
| final String name = 'pre-push'; | ||
|
|
||
| @override | ||
| final String description = | ||
| 'Validates that recent commits do not contain evaluation test credentials before "git push"'; | ||
|
|
||
| @override | ||
| Future<bool> run() async { | ||
| print('Running pre-push validation...'); | ||
|
|
||
| final ProcessResult logResult = await processRunner('git', <String>[ | ||
| 'log', | ||
| '-n', | ||
| '20', | ||
| '--format=%h%x00%an%x00%ae%x00%cn%x00%ce%x00%s', | ||
| ]); | ||
|
|
||
| if (logResult.exitCode != 0) { | ||
| print('Failed to check git commit history.'); | ||
| final String stderr = logResult.stderr?.toString().trim() ?? ''; | ||
| if (stderr.isNotEmpty) { | ||
| print(stderr); | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| final String stdoutStr = logResult.stdout?.toString() ?? ''; | ||
| final List<String> commitEntries = stdoutStr | ||
| .split('\n') | ||
| .map((String line) => line.trim()) | ||
| .where((String line) => line.isNotEmpty) | ||
| .toList(); | ||
|
|
||
| for (final entry in commitEntries) { | ||
| final List<String> fields = entry.split('\u0000'); | ||
| if (fields.length < 6) { | ||
| continue; | ||
| } | ||
| final String sha = fields[0]; | ||
| final String authorName = fields[1]; | ||
| final String authorEmail = fields[2]; | ||
| final String committerName = fields[3]; | ||
| final String committerEmail = fields[4]; | ||
| final String subject = fields[5]; | ||
|
|
||
| if (authorName == evalAuthorName || | ||
| authorEmail == evalAuthorEmail || | ||
| committerName == evalAuthorName || | ||
| committerEmail == evalAuthorEmail) { | ||
| print(''' | ||
| Pre-push check failed: Found commit(s) authored or committed with evaluation test credentials: | ||
| $sha | Author: $authorName <$authorEmail> | Committer: $committerName <$committerEmail> | $subject | ||
|
|
||
| Evaluation test commits must not be pushed. Clean or rebase your branch before pushing. | ||
| To bypass this check, push with --no-verify.'''); | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| print('Pre-push validation passed.'); | ||
| return true; | ||
| } | ||
|
Comment on lines
+52
to
+106
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Instead of checking the currently checked-out branch ( @override
Future<bool> run() async {
print('Running pre-push validation...');
final List<String> shasToCheck = [];
final bool canReadStdin = stdIn is! Stdin || !(stdIn as Stdin).hasTerminal;
if (canReadStdin) {
try {
final String input = await stdIn.transform(utf8.decoder).join();
for (final String line in input.split('\n')) {
final List<String> parts = line.trim().split(' ');
if (parts.length >= 4) {
final String localSha = parts[1];
if (localSha != '0000000000000000000000000000000000000000') {
shasToCheck.add(localSha);
}
}
}
} catch (_) {
// Fallback to HEAD if reading stdin fails.
}
}
if (shasToCheck.isEmpty) {
shasToCheck.add('HEAD');
}
for (final String sha in shasToCheck) {
final ProcessResult logResult = await processRunner('git', <String>[
'log',
'-n',
'20',
'--format=%h%x00%an%x00%ae%x00%cn%x00%ce%x00%s',
sha,
]);
if (logResult.exitCode != 0) {
print('Failed to check git commit history for $sha.');
final String stderr = logResult.stderr?.toString().trim() ?? '';
if (stderr.isNotEmpty) {
print(stderr);
}
return false;
}
final String stdoutStr = logResult.stdout?.toString() ?? '';
final List<String> commitEntries = stdoutStr
.split('\n')
.map((String line) => line.trim())
.where((String line) => line.isNotEmpty)
.toList();
for (final entry in commitEntries) {
final List<String> fields = entry.split('\u0000');
if (fields.length < 6) {
continue;
}
final String commitSha = fields[0];
final String authorName = fields[1];
final String authorEmail = fields[2];
final String committerName = fields[3];
final String committerEmail = fields[4];
final String subject = fields[5];
if (authorName == evalAuthorName ||
authorEmail.toLowerCase() == evalAuthorEmail ||
committerName == evalAuthorName ||
committerEmail.toLowerCase() == evalAuthorEmail) {
print('''
Pre-push check failed: Found commit(s) authored or committed with evaluation test credentials:
$commitSha | Author: $authorName <$authorEmail> | Committer: $committerName <$committerEmail> | $subject
Evaluation test commits must not be pushed. Clean or rebase your branch before pushing.
To bypass this check, push with --no-verify.''');
return false;
}
}
}
print('Pre-push validation passed.');
return true;
} |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| #!/usr/bin/env bash | ||
| set -e | ||
|
|
||
| HOOKS_DIR="$(dirname "$0")" | ||
| exec dart "$HOOKS_DIR/bin/main.dart" pre-push "$@" |
|
reidbaker marked this conversation as resolved.
|
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,122 @@ | ||||||||||||||||||||
| // Copyright 2013 The Flutter Authors | ||||||||||||||||||||
| // Use of this source code is governed by a BSD-style license that can be | ||||||||||||||||||||
| // found in the LICENSE file. | ||||||||||||||||||||
|
|
||||||||||||||||||||
| import 'dart:io'; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| import 'package:githooks/src/pre_push_command.dart'; | ||||||||||||||||||||
| import 'package:test/test.dart'; | ||||||||||||||||||||
|
Comment on lines
+5
to
+8
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add
Suggested change
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| void main() { | ||||||||||||||||||||
|
reidbaker marked this conversation as resolved.
|
||||||||||||||||||||
| group('pre-push hook', () { | ||||||||||||||||||||
| PrePushCommand createCommand( | ||||||||||||||||||||
| String gitLogOutput, { | ||||||||||||||||||||
| int exitCode = 0, | ||||||||||||||||||||
| String? stderr = '', | ||||||||||||||||||||
| List<List<String>>? capturedArgs, | ||||||||||||||||||||
| }) { | ||||||||||||||||||||
| return PrePushCommand( | ||||||||||||||||||||
| processRunner: | ||||||||||||||||||||
| (String executable, List<String> arguments, {String? workingDirectory}) async { | ||||||||||||||||||||
| capturedArgs?.add(arguments); | ||||||||||||||||||||
| if (executable == 'git' && arguments.contains('log')) { | ||||||||||||||||||||
| return ProcessResult(0, exitCode, gitLogOutput, stderr); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| return ProcessResult(0, 0, 'Success', ''); | ||||||||||||||||||||
| }, | ||||||||||||||||||||
| ); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
Comment on lines
+12
to
+28
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Update PrePushCommand createCommand(
String gitLogOutput, {
int exitCode = 0,
String? stderr = '',
List<List<String>>? capturedArgs,
Stream<List<int>>? stdIn,
}) {
return PrePushCommand(
processRunner:
(String executable, List<String> arguments, {String? workingDirectory}) async {
capturedArgs?.add(arguments);
if (executable == 'git' && arguments.contains('log')) {
return ProcessResult(0, exitCode, gitLogOutput, stderr);
}
return ProcessResult(0, 0, 'Success', '');
},
stdIn: stdIn ?? Stream<List<int>>.empty(),
);
} |
||||||||||||||||||||
|
|
||||||||||||||||||||
| String formatCommit({ | ||||||||||||||||||||
| String sha = 'abc1234', | ||||||||||||||||||||
| String authorName = 'Alice', | ||||||||||||||||||||
| String authorEmail = 'alice@google.com', | ||||||||||||||||||||
| String committerName = 'Alice', | ||||||||||||||||||||
| String committerEmail = 'alice@google.com', | ||||||||||||||||||||
| String subject = 'Valid commit', | ||||||||||||||||||||
| }) { | ||||||||||||||||||||
| return '$sha\u0000$authorName\u0000$authorEmail\u0000$committerName\u0000$committerEmail\u0000$subject\n'; | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| test('passes when recent commits contain no evaluation credentials', () async { | ||||||||||||||||||||
| final executedArguments = <List<String>>[]; | ||||||||||||||||||||
| final PrePushCommand command = createCommand( | ||||||||||||||||||||
| '${formatCommit(subject: 'Fix feature')}' | ||||||||||||||||||||
| '${formatCommit(sha: 'def5678', authorName: 'Bob', authorEmail: 'bob@example.com', committerName: 'Bob', committerEmail: 'bob@example.com', subject: 'Add unit tests')}', | ||||||||||||||||||||
| capturedArgs: executedArguments, | ||||||||||||||||||||
| ); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| final bool result = await command.run(); | ||||||||||||||||||||
| expect(result, isTrue); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| expect( | ||||||||||||||||||||
| executedArguments, | ||||||||||||||||||||
| anyElement( | ||||||||||||||||||||
| equals(<String>['log', '-n', '20', '--format=%h%x00%an%x00%ae%x00%cn%x00%ce%x00%s']), | ||||||||||||||||||||
| ), | ||||||||||||||||||||
| ); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
reidbaker marked this conversation as resolved.
|
||||||||||||||||||||
| test('passes when commit message contains eval credentials in subject', () async { | ||||||||||||||||||||
| final PrePushCommand command = createCommand( | ||||||||||||||||||||
| formatCommit(subject: 'Fix bug with eval-author@example.com and Eval Author'), | ||||||||||||||||||||
| ); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| final bool result = await command.run(); | ||||||||||||||||||||
| expect(result, isTrue); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| test('fails when recent commit is authored by Eval Author', () async { | ||||||||||||||||||||
| final PrePushCommand command = createCommand(formatCommit(authorName: 'Eval Author')); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| final bool result = await command.run(); | ||||||||||||||||||||
| expect(result, isFalse); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| test('fails when recent commit is authored by eval-author@example.com', () async { | ||||||||||||||||||||
| final PrePushCommand command = createCommand( | ||||||||||||||||||||
| formatCommit(authorEmail: 'eval-author@example.com'), | ||||||||||||||||||||
| ); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| final bool result = await command.run(); | ||||||||||||||||||||
| expect(result, isFalse); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| test('fails when recent commit is committed by Eval Author', () async { | ||||||||||||||||||||
| final PrePushCommand command = createCommand(formatCommit(committerName: 'Eval Author')); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| final bool result = await command.run(); | ||||||||||||||||||||
| expect(result, isFalse); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| test('fails when recent commit is committed by eval-author@example.com', () async { | ||||||||||||||||||||
| final PrePushCommand command = createCommand( | ||||||||||||||||||||
| formatCommit(committerEmail: 'eval-author@example.com'), | ||||||||||||||||||||
| ); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| final bool result = await command.run(); | ||||||||||||||||||||
| expect(result, isFalse); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| test('fails when git log execution fails', () async { | ||||||||||||||||||||
| final PrePushCommand command = createCommand('', exitCode: 1, stderr: 'Git fatal error'); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| final bool result = await command.run(); | ||||||||||||||||||||
| expect(result, isFalse); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| test('fails when git log execution fails with null stderr', () async { | ||||||||||||||||||||
| final PrePushCommand command = createCommand('', exitCode: 1, stderr: null); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| final bool result = await command.run(); | ||||||||||||||||||||
| expect(result, isFalse); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| test('passes when git log returns empty output', () async { | ||||||||||||||||||||
| final PrePushCommand command = createCommand(''); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| final bool result = await command.run(); | ||||||||||||||||||||
| expect(result, isTrue); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
| } | ||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Print the git hooks installation instructions when the
pre-pushhook file is missing. This matches the behavior whencore.hooksPathis incorrect and helps the user resolve the issue quickly.