From b0ea594f9843913a6e2180f73b30ed9fb0e0b1bf Mon Sep 17 00:00:00 2001 From: Reid Baker Date: Thu, 27 Aug 2026 16:56:07 -0400 Subject: [PATCH 1/6] Add pre-push Git hook to prevent eval commits and verify in check-readiness --- .../check-readiness/lib/check_readiness.dart | 13 +- .../check-readiness/test/check_test.dart | 131 +++++++++------ script/githooks/README.md | 13 +- script/githooks/lib/githooks.dart | 4 +- script/githooks/lib/src/pre_push_command.dart | 88 ++++++++++ script/githooks/pre-push | 5 + .../githooks/test/pre_push_command_test.dart | 152 ++++++++++++++++++ 7 files changed, 351 insertions(+), 55 deletions(-) create mode 100644 script/githooks/lib/src/pre_push_command.dart create mode 100755 script/githooks/pre-push create mode 100644 script/githooks/test/pre_push_command_test.dart 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..e6526242c67f 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 @@ -18,9 +18,9 @@ import 'package:test/test.dart'; import '../tool/check.dart'; class FakeProcessManager implements ProcessManager { - final Map canRunMock = {}; - final Map runMock = {}; - final List> runInvocations = []; + final Map canRunMock = {}; + final Map runMock = {}; + final List> runInvocations = >[]; @override bool canRun(dynamic executable, {String? workingDirectory}) { @@ -56,7 +56,7 @@ void main() { late FakeProcessManager processManager; late ReadinessChecker checker; late String workspaceRoot; - final List printLogs = []; + final List printLogs = []; setUp(() { fileSystem = MemoryFileSystem.test(); @@ -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(); @@ -92,64 +95,80 @@ void main() { expect(printLogs, contains('Environment is fully ready!')); }); - test('fails when a broken symlink is present', () async { - final Directory skillsDir = fileSystem + test('fails when git hooks are not configured', () async { + fileSystem .directory(fileSystem.path.join(workspaceRoot, '.agents', 'skills')) - ..createSync(recursive: true); + .createSync(recursive: true); - // MemoryFileSystem supports links - final Link link = fileSystem.link(fileSystem.path.join(skillsDir.path, 'broken_link')); - link.createSync('non_existent_target'); + processManager.runMock['git config --get core.hooksPath'] = ProcessResult(0, 1, '', 'not set'); final bool result = await runChecker(); expect(result, isFalse); expect( - printLogs.any((line) => line.contains('Found broken symlinks in .agents/skills:')), isTrue); + printLogs.any((line) => line.contains('Git hooks are not configured correctly')), + isTrue, + ); + expect(printLogs.any((line) => line.contains('dart run bin/install_hooks.dart')), isTrue); }); - test('fails when git is dirty', () async { + test('fails when git hooks path is wrong', () async { fileSystem .directory(fileSystem.path.join(workspaceRoot, '.agents', 'skills')) .createSync(recursive: true); - processManager.runMock['git status --porcelain'] = ProcessResult(0, 0, ' M file.txt\n', ''); + processManager.runMock['git config --get core.hooksPath'] = + ProcessResult(0, 0, '.git/hooks\n', ''); final bool result = await runChecker(); expect(result, isFalse); - expect( - printLogs, - contains( - 'Error: Git working directory is not clean. Please commit or stash your changes before starting new work.')); + expect(printLogs.any((line) => line.contains('expected "script/githooks"')), isTrue); }); - test('fails when git hooks are not configured', () async { + test('fails when pre-push hook file is missing', () async { fileSystem .directory(fileSystem.path.join(workspaceRoot, '.agents', 'skills')) .createSync(recursive: true); - processManager.runMock['git config --get core.hooksPath'] = ProcessResult(0, 1, '', ''); + 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 a broken symlink is present', () async { + final Directory skillsDir = fileSystem + .directory(fileSystem.path.join(workspaceRoot, '.agents', 'skills')) + ..createSync(recursive: true); + + // MemoryFileSystem supports links + final Link link = fileSystem.link(fileSystem.path.join(skillsDir.path, 'broken_link')); + link.createSync('non_existent_target'); final bool result = await runChecker(); expect(result, isFalse); expect( - printLogs.any((line) => line.contains('Git hooks are not configured correctly')), + printLogs.any((line) => line.contains('Found broken symlinks in .agents/skills:')), isTrue, ); }); - test('fails when git hooks point to incorrect path', () async { + test('fails when git is dirty', () async { fileSystem .directory(fileSystem.path.join(workspaceRoot, '.agents', 'skills')) .createSync(recursive: true); - processManager.runMock['git config --get core.hooksPath'] = - ProcessResult(0, 0, 'other/hooks\n', ''); + processManager.runMock['git status --porcelain'] = ProcessResult(0, 0, ' M file.txt\n', ''); final bool result = await runChecker(); expect(result, isFalse); expect( - printLogs.any((line) => line.contains('Git hooks are not configured correctly')), - isTrue, + printLogs, + contains( + 'Error: Git working directory is not clean. Please commit or stash your changes before starting new work.', + ), ); }); @@ -190,24 +209,26 @@ void main() { expect(printLogs, contains('Error: Failed to resolve dependencies.')); }); - test('does not return early and reports multiple failures if git is dirty and tools are missing', - () async { - fileSystem - .directory(fileSystem.path.join(workspaceRoot, '.agents', 'skills')) - .createSync(recursive: true); + test( + 'does not return early and reports multiple failures if git is dirty and tools are missing', + () async { + fileSystem + .directory(fileSystem.path.join(workspaceRoot, '.agents', 'skills')) + .createSync(recursive: true); - // Git returns dirty - processManager.runMock['git status --porcelain'] = ProcessResult(0, 0, ' M file.txt\n', ''); - // Flutter is missing - processManager.canRunMock['flutter'] = false; + // Git returns dirty + processManager.runMock['git status --porcelain'] = ProcessResult(0, 0, ' M file.txt\n', ''); + // Flutter is missing + processManager.canRunMock['flutter'] = false; - final bool result = await runChecker(); - expect(result, isFalse); + final bool result = await runChecker(); + expect(result, isFalse); - // Both errors should be printed - expect(printLogs.any((line) => line.contains('Git working directory is not clean')), isTrue); - expect(printLogs.any((line) => line.contains("'flutter' is not on the PATH")), isTrue); - }); + // Both errors should be printed + expect(printLogs.any((line) => line.contains('Git working directory is not clean')), isTrue); + expect(printLogs.any((line) => line.contains("'flutter' is not on the PATH")), isTrue); + }, + ); group('Windows style', () { late MemoryFileSystem winFileSystem; @@ -225,9 +246,23 @@ 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); + processManager.runMock['git config --get core.hooksPath'] = + ProcessResult(0, 0, r'script\githooks' '\n', ''); printLogs.clear(); }); + test('passes when core.hooksPath uses backslashes on Windows', () async { + winFileSystem + .directory(winFileSystem.path.join(winWorkspaceRoot, '.agents', 'skills')) + .createSync(recursive: true); + + final bool result = await winChecker.checkReadiness(winWorkspaceRoot); + expect(result, isTrue); + }); + test('fails when a broken symlink is present on Windows', () async { final Directory skillsDir = winFileSystem .directory(winFileSystem.path.join(winWorkspaceRoot, '.agents', 'skills')) @@ -238,18 +273,10 @@ void main() { final bool result = await winChecker.checkReadiness(winWorkspaceRoot); expect(result, isFalse); - expect(printLogs.any((line) => line.contains('Found broken symlinks in .agents/skills:')), - isTrue); - }); - - test('passes when git hooks use Windows backslashes', () async { - processManager.runMock['git config --get core.hooksPath'] = - ProcessResult(0, 0, r'script\githooks' '\n', ''); - processManager.runMock['git status --porcelain'] = ProcessResult(0, 0, '', ''); - - final bool result = await winChecker.checkReadiness(winWorkspaceRoot); - expect(result, isTrue); - expect(printLogs, contains('Git hooks are configured correctly.')); + expect( + printLogs.any((line) => line.contains('Found broken symlinks in .agents/skills:')), + isTrue, + ); }); }); 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..51e9cc754e35 --- /dev/null +++ b/script/githooks/lib/src/pre_push_command.dart @@ -0,0 +1,88 @@ +// 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 githook. +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 | Author: %an <%ae> | Committer: %cn <%ce> | %s', + ]); + + if (logResult.exitCode != 0) { + print('Failed to check git commit history.'); + if (logResult.stderr.toString().isNotEmpty) { + print(logResult.stderr); + } + return false; + } + + final stdoutStr = logResult.stdout as String; + final List commitLines = stdoutStr + .split('\n') + .map((String line) => line.trim()) + .where((String line) => line.isNotEmpty) + .toList(); + + final forbiddenIdentities = [evalAuthorEmail, evalAuthorName]; + + for (final commitLine in commitLines) { + for (final forbiddenIdentity in forbiddenIdentities) { + if (commitLine.contains(forbiddenIdentity)) { + print(''' +Pre-push check failed: Found commit(s) authored or committed with evaluation test credentials: + $commitLine + +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_push_command_test.dart b/script/githooks/test/pre_push_command_test.dart new file mode 100644 index 000000000000..c1b407d42d08 --- /dev/null +++ b/script/githooks/test/pre_push_command_test.dart @@ -0,0 +1,152 @@ +// 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', () { + test('passes when recent commits contain no evaluation credentials', () async { + final List> executedArguments = >[]; + final command = PrePushCommand( + processRunner: (String executable, List arguments, {String? workingDirectory}) async { + executedArguments.add(arguments); + if (executable == 'git' && arguments.contains('log')) { + return ProcessResult( + 0, + 0, + 'abc1234 | Author: Alice | Committer: Alice | Fix feature\n' + 'def5678 | Author: Bob | Committer: Bob | Add unit tests\n', + '', + ); + } + return ProcessResult(0, 0, 'Success', ''); + }, + ); + + final bool result = await command.run(); + expect(result, isTrue); + + expect( + executedArguments, + anyElement( + equals([ + 'log', + '-n', + '20', + '--format=%h | Author: %an <%ae> | Committer: %cn <%ce> | %s', + ]), + ), + ); + }); + + test('fails when recent commit is authored by Eval Author', () async { + final command = PrePushCommand( + processRunner: (String executable, List arguments, {String? workingDirectory}) async { + if (executable == 'git' && arguments.contains('log')) { + return ProcessResult( + 0, + 0, + 'abc1234 | Author: Eval Author | Committer: Contributor | Eval change\n', + '', + ); + } + return ProcessResult(0, 0, 'Success', ''); + }, + ); + + final bool result = await command.run(); + expect(result, isFalse); + }); + + test('fails when recent commit is authored by eval-author@example.com', () async { + final command = PrePushCommand( + processRunner: (String executable, List arguments, {String? workingDirectory}) async { + if (executable == 'git' && arguments.contains('log')) { + return ProcessResult( + 0, + 0, + 'abc1234 | Author: Contributor | Committer: Contributor | Eval change\n', + '', + ); + } + return ProcessResult(0, 0, 'Success', ''); + }, + ); + + final bool result = await command.run(); + expect(result, isFalse); + }); + + test('fails when recent commit is committed by Eval Author', () async { + final command = PrePushCommand( + processRunner: (String executable, List arguments, {String? workingDirectory}) async { + if (executable == 'git' && arguments.contains('log')) { + return ProcessResult( + 0, + 0, + 'abc1234 | Author: Contributor | Committer: Eval Author | Eval change\n', + '', + ); + } + return ProcessResult(0, 0, 'Success', ''); + }, + ); + + final bool result = await command.run(); + expect(result, isFalse); + }); + + test('fails when recent commit is committed by eval-author@example.com', () async { + final command = PrePushCommand( + processRunner: (String executable, List arguments, {String? workingDirectory}) async { + if (executable == 'git' && arguments.contains('log')) { + return ProcessResult( + 0, + 0, + 'abc1234 | Author: Contributor | Committer: Contributor | Eval change\n', + '', + ); + } + return ProcessResult(0, 0, 'Success', ''); + }, + ); + + final bool result = await command.run(); + expect(result, isFalse); + }); + + test('fails when git log execution fails', () async { + final command = PrePushCommand( + processRunner: + (String executable, List arguments, {String? workingDirectory}) async { + if (executable == 'git' && arguments.contains('log')) { + return ProcessResult(0, 1, '', 'Git fatal error'); + } + return ProcessResult(0, 0, 'Success', ''); + }, + ); + + final bool result = await command.run(); + expect(result, isFalse); + }); + + test('passes when git log returns empty output', () async { + final command = PrePushCommand( + processRunner: + (String executable, List arguments, {String? workingDirectory}) async { + if (executable == 'git' && arguments.contains('log')) { + return ProcessResult(0, 0, '', ''); + } + return ProcessResult(0, 0, 'Success', ''); + }, + ); + + final bool result = await command.run(); + expect(result, isTrue); + }); + }); +} From 32e56bb173e65ba7a88e97faf572c03ca21204c2 Mon Sep 17 00:00:00 2001 From: Reid Baker Date: Thu, 27 Aug 2026 17:17:15 -0400 Subject: [PATCH 2/6] Address review feedback: add CI test target, refine docs, reduce test duplication --- .ci/scripts/analyze_repo_tools.sh | 3 + .ci/scripts/githooks_tests.sh | 8 + .ci/targets/repo_tools_tests.yaml | 2 + script/githooks/lib/src/pre_push_command.dart | 42 ++++-- .../githooks/test/pre_push_command_test.dart | 141 +++++++----------- 5 files changed, 95 insertions(+), 101 deletions(-) create mode 100755 .ci/scripts/githooks_tests.sh diff --git a/.ci/scripts/analyze_repo_tools.sh b/.ci/scripts/analyze_repo_tools.sh index 2c7df6ebb8b3..5c9e46d30d33 100755 --- a/.ci/scripts/analyze_repo_tools.sh +++ b/.ci/scripts/analyze_repo_tools.sh @@ -6,3 +6,6 @@ set -e cd script/tool dart analyze --fatal-infos + +cd ../githooks +dart analyze --fatal-infos diff --git a/.ci/scripts/githooks_tests.sh b/.ci/scripts/githooks_tests.sh new file mode 100755 index 000000000000..bbeb7822712c --- /dev/null +++ b/.ci/scripts/githooks_tests.sh @@ -0,0 +1,8 @@ +#!/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 test 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/script/githooks/lib/src/pre_push_command.dart b/script/githooks/lib/src/pre_push_command.dart index 51e9cc754e35..61167ad27fd5 100644 --- a/script/githooks/lib/src/pre_push_command.dart +++ b/script/githooks/lib/src/pre_push_command.dart @@ -13,7 +13,15 @@ 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 githook. +/// 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({ @@ -48,7 +56,7 @@ class PrePushCommand extends Command { 'log', '-n', '20', - '--format=%h | Author: %an <%ae> | Committer: %cn <%ce> | %s', + '--format=%h%x00%an%x00%ae%x00%cn%x00%ce%x00%s', ]); if (logResult.exitCode != 0) { @@ -60,25 +68,35 @@ class PrePushCommand extends Command { } final stdoutStr = logResult.stdout as String; - final List commitLines = stdoutStr + final List commitEntries = stdoutStr .split('\n') .map((String line) => line.trim()) .where((String line) => line.isNotEmpty) .toList(); - final forbiddenIdentities = [evalAuthorEmail, evalAuthorName]; - - for (final commitLine in commitLines) { - for (final forbiddenIdentity in forbiddenIdentities) { - if (commitLine.contains(forbiddenIdentity)) { - print(''' + 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: - $commitLine + $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; - } + return false; } } diff --git a/script/githooks/test/pre_push_command_test.dart b/script/githooks/test/pre_push_command_test.dart index c1b407d42d08..1821600b51ca 100644 --- a/script/githooks/test/pre_push_command_test.dart +++ b/script/githooks/test/pre_push_command_test.dart @@ -9,22 +9,41 @@ 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 List> executedArguments = >[]; - final command = PrePushCommand( - processRunner: (String executable, List arguments, {String? workingDirectory}) async { - executedArguments.add(arguments); - if (executable == 'git' && arguments.contains('log')) { - return ProcessResult( - 0, - 0, - 'abc1234 | Author: Alice | Committer: Alice | Fix feature\n' - 'def5678 | Author: Bob | Committer: Bob | Add unit tests\n', - '', - ); - } - return ProcessResult(0, 0, 'Success', ''); - }, + 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(); @@ -33,48 +52,30 @@ void main() { expect( executedArguments, anyElement( - equals([ - 'log', - '-n', - '20', - '--format=%h | Author: %an <%ae> | Committer: %cn <%ce> | %s', - ]), + equals(['log', '-n', '20', '--format=%h%x00%an%x00%ae%x00%cn%x00%ce%x00%s']), ), ); }); - test('fails when recent commit is authored by Eval Author', () async { - final command = PrePushCommand( - processRunner: (String executable, List arguments, {String? workingDirectory}) async { - if (executable == 'git' && arguments.contains('log')) { - return ProcessResult( - 0, - 0, - 'abc1234 | Author: Eval Author | Committer: Contributor | Eval change\n', - '', - ); - } - return ProcessResult(0, 0, 'Success', ''); - }, + 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 command = PrePushCommand( - processRunner: (String executable, List arguments, {String? workingDirectory}) async { - if (executable == 'git' && arguments.contains('log')) { - return ProcessResult( - 0, - 0, - 'abc1234 | Author: Contributor | Committer: Contributor | Eval change\n', - '', - ); - } - return ProcessResult(0, 0, 'Success', ''); - }, + final PrePushCommand command = createCommand( + formatCommit(authorEmail: 'eval-author@example.com'), ); final bool result = await command.run(); @@ -82,37 +83,15 @@ void main() { }); test('fails when recent commit is committed by Eval Author', () async { - final command = PrePushCommand( - processRunner: (String executable, List arguments, {String? workingDirectory}) async { - if (executable == 'git' && arguments.contains('log')) { - return ProcessResult( - 0, - 0, - 'abc1234 | Author: Contributor | Committer: Eval Author | Eval change\n', - '', - ); - } - return ProcessResult(0, 0, 'Success', ''); - }, - ); + 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 command = PrePushCommand( - processRunner: (String executable, List arguments, {String? workingDirectory}) async { - if (executable == 'git' && arguments.contains('log')) { - return ProcessResult( - 0, - 0, - 'abc1234 | Author: Contributor | Committer: Contributor | Eval change\n', - '', - ); - } - return ProcessResult(0, 0, 'Success', ''); - }, + final PrePushCommand command = createCommand( + formatCommit(committerEmail: 'eval-author@example.com'), ); final bool result = await command.run(); @@ -120,30 +99,14 @@ void main() { }); test('fails when git log execution fails', () async { - final command = PrePushCommand( - processRunner: - (String executable, List arguments, {String? workingDirectory}) async { - if (executable == 'git' && arguments.contains('log')) { - return ProcessResult(0, 1, '', 'Git fatal error'); - } - return ProcessResult(0, 0, 'Success', ''); - }, - ); + final PrePushCommand command = createCommand('', exitCode: 1, stderr: 'Git fatal error'); final bool result = await command.run(); expect(result, isFalse); }); test('passes when git log returns empty output', () async { - final command = PrePushCommand( - processRunner: - (String executable, List arguments, {String? workingDirectory}) async { - if (executable == 'git' && arguments.contains('log')) { - return ProcessResult(0, 0, '', ''); - } - return ProcessResult(0, 0, 'Success', ''); - }, - ); + final PrePushCommand command = createCommand(''); final bool result = await command.run(); expect(result, isTrue); From bf6d55e809b7cef536742f9e77ebe277e4a7eaef Mon Sep 17 00:00:00 2001 From: Reid Baker Date: Thu, 27 Aug 2026 17:24:13 -0400 Subject: [PATCH 3/6] Preserve original test order in check_test.dart for cleaner diff --- .../check-readiness/test/check_test.dart | 129 +++++++++--------- 1 file changed, 61 insertions(+), 68 deletions(-) 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 e6526242c67f..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 @@ -18,9 +18,9 @@ import 'package:test/test.dart'; import '../tool/check.dart'; class FakeProcessManager implements ProcessManager { - final Map canRunMock = {}; - final Map runMock = {}; - final List> runInvocations = >[]; + final Map canRunMock = {}; + final Map runMock = {}; + final List> runInvocations = []; @override bool canRun(dynamic executable, {String? workingDirectory}) { @@ -56,7 +56,7 @@ void main() { late FakeProcessManager processManager; late ReadinessChecker checker; late String workspaceRoot; - final List printLogs = []; + final List printLogs = []; setUp(() { fileSystem = MemoryFileSystem.test(); @@ -95,81 +95,79 @@ void main() { expect(printLogs, contains('Environment is fully ready!')); }); - test('fails when git hooks are not configured', () async { - fileSystem + test('fails when a broken symlink is present', () async { + final Directory skillsDir = fileSystem .directory(fileSystem.path.join(workspaceRoot, '.agents', 'skills')) - .createSync(recursive: true); + ..createSync(recursive: true); - processManager.runMock['git config --get core.hooksPath'] = ProcessResult(0, 1, '', 'not set'); + // MemoryFileSystem supports links + final Link link = fileSystem.link(fileSystem.path.join(skillsDir.path, 'broken_link')); + link.createSync('non_existent_target'); final bool result = await runChecker(); expect(result, isFalse); expect( - printLogs.any((line) => line.contains('Git hooks are not configured correctly')), - isTrue, - ); - expect(printLogs.any((line) => line.contains('dart run bin/install_hooks.dart')), isTrue); + printLogs.any((line) => line.contains('Found broken symlinks in .agents/skills:')), isTrue); }); - test('fails when git hooks path is wrong', () async { + test('fails when git is dirty', () async { fileSystem .directory(fileSystem.path.join(workspaceRoot, '.agents', 'skills')) .createSync(recursive: true); - processManager.runMock['git config --get core.hooksPath'] = - ProcessResult(0, 0, '.git/hooks\n', ''); + processManager.runMock['git status --porcelain'] = ProcessResult(0, 0, ' M file.txt\n', ''); final bool result = await runChecker(); expect(result, isFalse); - expect(printLogs.any((line) => line.contains('expected "script/githooks"')), isTrue); + expect( + printLogs, + contains( + 'Error: Git working directory is not clean. Please commit or stash your changes before starting new work.')); }); - test('fails when pre-push hook file is missing', () async { + test('fails when git hooks are not configured', () async { fileSystem .directory(fileSystem.path.join(workspaceRoot, '.agents', 'skills')) .createSync(recursive: true); - fileSystem - .file(fileSystem.path.join(workspaceRoot, 'script', 'githooks', 'pre-push')) - .deleteSync(); + processManager.runMock['git config --get core.hooksPath'] = ProcessResult(0, 1, '', ''); final bool result = await runChecker(); expect(result, isFalse); - expect(printLogs.any((line) => line.contains('Git pre-push hook is missing')), isTrue); + expect( + printLogs.any((line) => line.contains('Git hooks are not configured correctly')), + isTrue, + ); }); - test('fails when a broken symlink is present', () async { - final Directory skillsDir = fileSystem + test('fails when git hooks point to incorrect path', () async { + fileSystem .directory(fileSystem.path.join(workspaceRoot, '.agents', 'skills')) - ..createSync(recursive: true); + .createSync(recursive: true); - // MemoryFileSystem supports links - final Link link = fileSystem.link(fileSystem.path.join(skillsDir.path, 'broken_link')); - link.createSync('non_existent_target'); + processManager.runMock['git config --get core.hooksPath'] = + ProcessResult(0, 0, 'other/hooks\n', ''); final bool result = await runChecker(); expect(result, isFalse); expect( - printLogs.any((line) => line.contains('Found broken symlinks in .agents/skills:')), + printLogs.any((line) => line.contains('Git hooks are not configured correctly')), isTrue, ); }); - test('fails when git is dirty', () async { + test('fails when pre-push hook file is missing', () async { fileSystem .directory(fileSystem.path.join(workspaceRoot, '.agents', 'skills')) .createSync(recursive: true); - processManager.runMock['git status --porcelain'] = ProcessResult(0, 0, ' M file.txt\n', ''); + fileSystem + .file(fileSystem.path.join(workspaceRoot, 'script', 'githooks', 'pre-push')) + .deleteSync(); final bool result = await runChecker(); expect(result, isFalse); - expect( - printLogs, - contains( - 'Error: Git working directory is not clean. Please commit or stash your changes before starting new work.', - ), - ); + expect(printLogs.any((line) => line.contains('Git pre-push hook is missing')), isTrue); }); test('fails when flutter is missing', () async { @@ -209,26 +207,24 @@ void main() { expect(printLogs, contains('Error: Failed to resolve dependencies.')); }); - test( - 'does not return early and reports multiple failures if git is dirty and tools are missing', - () async { - fileSystem - .directory(fileSystem.path.join(workspaceRoot, '.agents', 'skills')) - .createSync(recursive: true); + test('does not return early and reports multiple failures if git is dirty and tools are missing', + () async { + fileSystem + .directory(fileSystem.path.join(workspaceRoot, '.agents', 'skills')) + .createSync(recursive: true); - // Git returns dirty - processManager.runMock['git status --porcelain'] = ProcessResult(0, 0, ' M file.txt\n', ''); - // Flutter is missing - processManager.canRunMock['flutter'] = false; + // Git returns dirty + processManager.runMock['git status --porcelain'] = ProcessResult(0, 0, ' M file.txt\n', ''); + // Flutter is missing + processManager.canRunMock['flutter'] = false; - final bool result = await runChecker(); - expect(result, isFalse); + final bool result = await runChecker(); + expect(result, isFalse); - // Both errors should be printed - expect(printLogs.any((line) => line.contains('Git working directory is not clean')), isTrue); - expect(printLogs.any((line) => line.contains("'flutter' is not on the PATH")), isTrue); - }, - ); + // Both errors should be printed + expect(printLogs.any((line) => line.contains('Git working directory is not clean')), isTrue); + expect(printLogs.any((line) => line.contains("'flutter' is not on the PATH")), isTrue); + }); group('Windows style', () { late MemoryFileSystem winFileSystem; @@ -249,20 +245,9 @@ void main() { winFileSystem .file(winFileSystem.path.join(winWorkspaceRoot, 'script', 'githooks', 'pre-push')) .createSync(recursive: true); - processManager.runMock['git config --get core.hooksPath'] = - ProcessResult(0, 0, r'script\githooks' '\n', ''); printLogs.clear(); }); - test('passes when core.hooksPath uses backslashes on Windows', () async { - winFileSystem - .directory(winFileSystem.path.join(winWorkspaceRoot, '.agents', 'skills')) - .createSync(recursive: true); - - final bool result = await winChecker.checkReadiness(winWorkspaceRoot); - expect(result, isTrue); - }); - test('fails when a broken symlink is present on Windows', () async { final Directory skillsDir = winFileSystem .directory(winFileSystem.path.join(winWorkspaceRoot, '.agents', 'skills')) @@ -273,10 +258,18 @@ void main() { final bool result = await winChecker.checkReadiness(winWorkspaceRoot); expect(result, isFalse); - expect( - printLogs.any((line) => line.contains('Found broken symlinks in .agents/skills:')), - isTrue, - ); + expect(printLogs.any((line) => line.contains('Found broken symlinks in .agents/skills:')), + isTrue); + }); + + test('passes when git hooks use Windows backslashes', () async { + processManager.runMock['git config --get core.hooksPath'] = + ProcessResult(0, 0, r'script\githooks' '\n', ''); + processManager.runMock['git status --porcelain'] = ProcessResult(0, 0, '', ''); + + final bool result = await winChecker.checkReadiness(winWorkspaceRoot); + expect(result, isTrue); + expect(printLogs, contains('Git hooks are configured correctly.')); }); }); From 5413c73315ba5a777151e799b9ef508ff8065e13 Mon Sep 17 00:00:00 2001 From: Reid Baker Date: Fri, 28 Aug 2026 10:08:04 -0400 Subject: [PATCH 4/6] Fix CI test setup for script/githooks: add pub get and normalize test path separators --- .ci/scripts/analyze_repo_tools.sh | 2 ++ .ci/scripts/githooks_tests.sh | 1 + .ci/scripts/prepare_tool.sh | 3 +++ script/githooks/test/pre_commit_command_test.dart | 11 +++++++++-- 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.ci/scripts/analyze_repo_tools.sh b/.ci/scripts/analyze_repo_tools.sh index 5c9e46d30d33..c00186f78db4 100755 --- a/.ci/scripts/analyze_repo_tools.sh +++ b/.ci/scripts/analyze_repo_tools.sh @@ -5,7 +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 index bbeb7822712c..38e625345624 100755 --- a/.ci/scripts/githooks_tests.sh +++ b/.ci/scripts/githooks_tests.sh @@ -5,4 +5,5 @@ 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/script/githooks/test/pre_commit_command_test.dart b/script/githooks/test/pre_commit_command_test.dart index 0f8c9e2ca79e..d1bebaaadc67 100644 --- a/script/githooks/test/pre_commit_command_test.dart +++ b/script/githooks/test/pre_commit_command_test.dart @@ -5,11 +5,18 @@ 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 { From 77f8c68f93233a76379d4f1b2dc7eedb6622e82a Mon Sep 17 00:00:00 2001 From: Reid Baker Date: Fri, 28 Aug 2026 15:52:47 -0400 Subject: [PATCH 5/6] Safely handle null stderr in PrePushCommand --- script/githooks/lib/src/pre_push_command.dart | 7 ++++--- script/githooks/test/pre_commit_command_test.dart | 8 +------- script/githooks/test/pre_push_command_test.dart | 9 ++++++++- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/script/githooks/lib/src/pre_push_command.dart b/script/githooks/lib/src/pre_push_command.dart index 61167ad27fd5..df916d0bef75 100644 --- a/script/githooks/lib/src/pre_push_command.dart +++ b/script/githooks/lib/src/pre_push_command.dart @@ -61,13 +61,14 @@ class PrePushCommand extends Command { if (logResult.exitCode != 0) { print('Failed to check git commit history.'); - if (logResult.stderr.toString().isNotEmpty) { - print(logResult.stderr); + final String stderr = logResult.stderr?.toString().trim() ?? ''; + if (stderr.isNotEmpty) { + print(stderr); } return false; } - final stdoutStr = logResult.stdout as String; + final String stdoutStr = logResult.stdout?.toString() ?? ''; final List commitEntries = stdoutStr .split('\n') .map((String line) => line.trim()) diff --git a/script/githooks/test/pre_commit_command_test.dart b/script/githooks/test/pre_commit_command_test.dart index d1bebaaadc67..0c0ae1d0adf0 100644 --- a/script/githooks/test/pre_commit_command_test.dart +++ b/script/githooks/test/pre_commit_command_test.dart @@ -10,13 +10,7 @@ import 'package:test/test.dart'; void main() { final String repoRoot = p.joinAll(['mock', 'repo', 'root']); - final String toolScript = p.join( - repoRoot, - 'script', - 'tool', - 'bin', - 'flutter_plugin_tools.dart', - ); + 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 index 1821600b51ca..b4229cd12103 100644 --- a/script/githooks/test/pre_push_command_test.dart +++ b/script/githooks/test/pre_push_command_test.dart @@ -12,7 +12,7 @@ void main() { PrePushCommand createCommand( String gitLogOutput, { int exitCode = 0, - String stderr = '', + dynamic stderr = '', List>? capturedArgs, }) { return PrePushCommand( @@ -105,6 +105,13 @@ void main() { 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(''); From 776e479819116ef2992be2ea5f9fd7f886ff6953 Mon Sep 17 00:00:00 2001 From: Reid Baker Date: Fri, 28 Aug 2026 15:56:54 -0400 Subject: [PATCH 6/6] Use String? instead of dynamic in pre_push_command_test.dart --- script/githooks/test/pre_push_command_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/githooks/test/pre_push_command_test.dart b/script/githooks/test/pre_push_command_test.dart index b4229cd12103..404a6236e9ab 100644 --- a/script/githooks/test/pre_push_command_test.dart +++ b/script/githooks/test/pre_push_command_test.dart @@ -12,7 +12,7 @@ void main() { PrePushCommand createCommand( String gitLogOutput, { int exitCode = 0, - dynamic stderr = '', + String? stderr = '', List>? capturedArgs, }) { return PrePushCommand(