diff --git a/.ci/scripts/analyze_repo_tools.sh b/.ci/scripts/analyze_repo_tools.sh index 2c7df6ebb8b3..c00186f78db4 100755 --- a/.ci/scripts/analyze_repo_tools.sh +++ b/.ci/scripts/analyze_repo_tools.sh @@ -5,4 +5,9 @@ set -e cd script/tool +dart pub get +dart analyze --fatal-infos + +cd ../githooks +dart pub get dart analyze --fatal-infos diff --git a/.ci/scripts/githooks_tests.sh b/.ci/scripts/githooks_tests.sh new file mode 100755 index 000000000000..38e625345624 --- /dev/null +++ b/.ci/scripts/githooks_tests.sh @@ -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 diff --git a/.ci/scripts/prepare_tool.sh b/.ci/scripts/prepare_tool.sh index 29f2b38ce325..66bfa2357ba5 100755 --- a/.ci/scripts/prepare_tool.sh +++ b/.ci/scripts/prepare_tool.sh @@ -11,5 +11,8 @@ git branch main origin/main cd script/tool dart pub get +cd ../githooks +dart pub get + cd ../flutter_goldens flutter pub get diff --git a/.ci/targets/repo_tools_tests.yaml b/.ci/targets/repo_tools_tests.yaml index d484e268e97a..5d62eb2375f1 100644 --- a/.ci/targets/repo_tools_tests.yaml +++ b/.ci/targets/repo_tools_tests.yaml @@ -4,5 +4,7 @@ tasks: infra_step: true # Note infra steps failing prevents "always" from running. - name: tool unit tests script: .ci/scripts/plugin_tools_tests.sh + - name: githooks unit tests + script: .ci/scripts/githooks_tests.sh - name: flutter_goldens unit tests script: .ci/scripts/flutter_goldens_tests.sh diff --git a/packages/camera/camera_android_camerax/.agents/skills/check-readiness/lib/check_readiness.dart b/packages/camera/camera_android_camerax/.agents/skills/check-readiness/lib/check_readiness.dart index 158604ee27ba..a3f5651e7273 100644 --- a/packages/camera/camera_android_camerax/.agents/skills/check-readiness/lib/check_readiness.dart +++ b/packages/camera/camera_android_camerax/.agents/skills/check-readiness/lib/check_readiness.dart @@ -146,6 +146,17 @@ class ReadinessChecker { return false; } + final String? repoRoot = _findRepoRoot(workspaceRoot); + if (repoRoot != null) { + final File prePushFile = _fileSystem.file( + _fileSystem.path.join(repoRoot, 'script', 'githooks', 'pre-push'), + ); + if (!prePushFile.existsSync()) { + _log('Error: Git pre-push hook is missing at "${prePushFile.path}".'); + return false; + } + } + _log('Git hooks are configured correctly.'); return true; } @@ -186,7 +197,7 @@ class ReadinessChecker { return false; } final ProcessResult activateResult = await _processManager.run( - [ + [ 'dart', 'pub', 'global', diff --git a/packages/camera/camera_android_camerax/.agents/skills/check-readiness/test/check_test.dart b/packages/camera/camera_android_camerax/.agents/skills/check-readiness/test/check_test.dart index 08aba8076c4c..23ea2a85baf8 100644 --- a/packages/camera/camera_android_camerax/.agents/skills/check-readiness/test/check_test.dart +++ b/packages/camera/camera_android_camerax/.agents/skills/check-readiness/test/check_test.dart @@ -68,6 +68,9 @@ void main() { ); workspaceRoot = fileSystem.path.absolute('workspace'); fileSystem.file(fileSystem.path.join(workspaceRoot, '.git')).createSync(recursive: true); + fileSystem + .file(fileSystem.path.join(workspaceRoot, 'script', 'githooks', 'pre-push')) + .createSync(recursive: true); processManager.runMock['git config --get core.hooksPath'] = ProcessResult(0, 0, 'script/githooks\n', ''); printLogs.clear(); @@ -153,6 +156,20 @@ void main() { ); }); + test('fails when pre-push hook file is missing', () async { + fileSystem + .directory(fileSystem.path.join(workspaceRoot, '.agents', 'skills')) + .createSync(recursive: true); + + fileSystem + .file(fileSystem.path.join(workspaceRoot, 'script', 'githooks', 'pre-push')) + .deleteSync(); + + final bool result = await runChecker(); + expect(result, isFalse); + expect(printLogs.any((line) => line.contains('Git pre-push hook is missing')), isTrue); + }); + test('fails when flutter is missing', () async { fileSystem .directory(fileSystem.path.join(workspaceRoot, '.agents', 'skills')) @@ -225,6 +242,9 @@ void main() { winFileSystem .file(winFileSystem.path.join(winWorkspaceRoot, '.git')) .createSync(recursive: true); + winFileSystem + .file(winFileSystem.path.join(winWorkspaceRoot, 'script', 'githooks', 'pre-push')) + .createSync(recursive: true); printLogs.clear(); }); diff --git a/script/githooks/README.md b/script/githooks/README.md index cb995a2d8014..43f5c23c6d8b 100644 --- a/script/githooks/README.md +++ b/script/githooks/README.md @@ -54,10 +54,11 @@ rm .git/hooks/pre-commit ### Bypass Hooks Temporarily -To skip running hooks for a single action, pass the `--no-verify` flag. For example, to bypass the pre-commit hook during a commit: +To skip running hooks for a single action, pass the `--no-verify` flag. For example, to bypass the pre-commit hook during a commit or pre-push hook during a push: ```bash git commit --no-verify +git push --no-verify ``` ## Available Hooks @@ -74,3 +75,13 @@ If either check fails, it aborts the commit. To bypass the hook (for a WIP commi ```bash git commit -m "WIP" --no-verify ``` + +### pre-push + +The `pre-push` hook runs automatically when you run `git push` and inspects the last 20 commits to ensure that no commits authored or committed using evaluation credentials (`Eval Author` or `eval-author@example.com`) are pushed. + +If an evaluation commit is found, it aborts the push. To bypass the hook, use `--no-verify`: + +```bash +git push --no-verify +``` diff --git a/script/githooks/lib/githooks.dart b/script/githooks/lib/githooks.dart index d8f6fc81d9ac..e20794bbeab5 100644 --- a/script/githooks/lib/githooks.dart +++ b/script/githooks/lib/githooks.dart @@ -5,11 +5,13 @@ import 'package:args/command_runner.dart'; import 'src/pre_commit_command.dart'; +import 'src/pre_push_command.dart'; /// Runs the githooks command line utility. Future run(List args) async { final runner = CommandRunner('githooks', 'Git hooks for flutter/packages') - ..addCommand(PreCommitCommand()); + ..addCommand(PreCommitCommand()) + ..addCommand(PrePushCommand()); final bool success = await runner.run(args) ?? false; return success ? 0 : 1; diff --git a/script/githooks/lib/src/pre_push_command.dart b/script/githooks/lib/src/pre_push_command.dart new file mode 100644 index 000000000000..df916d0bef75 --- /dev/null +++ b/script/githooks/lib/src/pre_push_command.dart @@ -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'; + +/// The author name used in evaluation test commits. +const String evalAuthorName = 'Eval Author'; + +/// 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 { + /// Creates a [PrePushCommand]. + PrePushCommand({ + Future Function( + String executable, + List arguments, { + String? workingDirectory, + })? + processRunner, + }) : processRunner = processRunner ?? Process.run; + + /// The process runner injected for testing. + final Future Function( + String executable, + List arguments, { + String? workingDirectory, + }) + processRunner; + + @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 run() async { + print('Running pre-push validation...'); + + final ProcessResult logResult = await processRunner('git', [ + '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 commitEntries = stdoutStr + .split('\n') + .map((String line) => line.trim()) + .where((String line) => line.isNotEmpty) + .toList(); + + for (final entry in commitEntries) { + final List 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; + } +} diff --git a/script/githooks/pre-push b/script/githooks/pre-push new file mode 100755 index 000000000000..af8e9b30db68 --- /dev/null +++ b/script/githooks/pre-push @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -e + +HOOKS_DIR="$(dirname "$0")" +exec dart "$HOOKS_DIR/bin/main.dart" pre-push "$@" diff --git a/script/githooks/test/pre_commit_command_test.dart b/script/githooks/test/pre_commit_command_test.dart index 0f8c9e2ca79e..0c0ae1d0adf0 100644 --- a/script/githooks/test/pre_commit_command_test.dart +++ b/script/githooks/test/pre_commit_command_test.dart @@ -5,11 +5,12 @@ import 'dart:io'; import 'package:githooks/src/pre_commit_command.dart'; +import 'package:path/path.dart' as p; import 'package:test/test.dart'; void main() { - const repoRoot = '/mock/repo/root'; - const toolScript = '$repoRoot/script/tool/bin/flutter_plugin_tools.dart'; + final String repoRoot = p.joinAll(['mock', 'repo', 'root']); + final String toolScript = p.join(repoRoot, 'script', 'tool', 'bin', 'flutter_plugin_tools.dart'); group('pre-commit hook', () { test('passes when both format and analyze succeed', () async { diff --git a/script/githooks/test/pre_push_command_test.dart b/script/githooks/test/pre_push_command_test.dart new file mode 100644 index 000000000000..404a6236e9ab --- /dev/null +++ b/script/githooks/test/pre_push_command_test.dart @@ -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'; + +void main() { + group('pre-push hook', () { + PrePushCommand createCommand( + String gitLogOutput, { + int exitCode = 0, + String? stderr = '', + List>? capturedArgs, + }) { + return PrePushCommand( + processRunner: + (String executable, List arguments, {String? workingDirectory}) async { + capturedArgs?.add(arguments); + if (executable == 'git' && arguments.contains('log')) { + return ProcessResult(0, exitCode, gitLogOutput, stderr); + } + return ProcessResult(0, 0, 'Success', ''); + }, + ); + } + + 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 = >[]; + 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(['log', '-n', '20', '--format=%h%x00%an%x00%ae%x00%cn%x00%ce%x00%s']), + ), + ); + }); + + 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); + }); + }); +}