Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .ci/scripts/analyze_repo_tools.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 9 additions & 0 deletions .ci/scripts/githooks_tests.sh
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
3 changes: 3 additions & 0 deletions .ci/scripts/prepare_tool.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions .ci/targets/repo_tools_tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Comment on lines +149 to +158

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Print the git hooks installation instructions when the pre-push hook file is missing. This matches the behavior when core.hooksPath is incorrect and helps the user resolve the issue quickly.

    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}".');
        final String githooksDir = _fileSystem.path.join(repoRoot, 'script', 'githooks');
        _log(
          'To install git hooks, run:\n' 
          '  (cd $githooksDir && dart pub get && dart run bin/install_hooks.dart)',
        );
        return false;
      }
    }


_log('Git hooks are configured correctly.');
return true;
}
Expand Down Expand Up @@ -186,7 +197,7 @@ class ReadinessChecker {
return false;
}
final ProcessResult activateResult = await _processManager.run(
[
<String>[
'dart',
'pub',
'global',
Expand Down
Comment thread
reidbaker marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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'))
Expand Down Expand Up @@ -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();
});

Expand Down
13 changes: 12 additions & 1 deletion script/githooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
```
4 changes: 3 additions & 1 deletion script/githooks/lib/githooks.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<int> run(List<String> args) async {
final runner = CommandRunner<bool>('githooks', 'Git hooks for flutter/packages')
..addCommand(PreCommitCommand());
..addCommand(PreCommitCommand())
..addCommand(PrePushCommand());

final bool success = await runner.run(args) ?? false;
return success ? 0 : 1;
Expand Down
107 changes: 107 additions & 0 deletions script/githooks/lib/src/pre_push_command.dart
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Add import 'dart:convert'; to support decoding and parsing the standard input stream when reading the refs being pushed.

Suggested change
import 'dart:io';
import 'package:args/command_runner.dart';
import 'dart:convert';
import 'dart:io';
import 'package:args/command_runner.dart';


/// The author name used in evaluation test commits.
const String evalAuthorName = 'Eval Author';
Comment thread
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Add an optional stdIn parameter to the constructor and store it as a field. This allows the command to read the refs being pushed from standard input while remaining fully testable by injecting a mock stream in unit tests.

  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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Instead of checking the currently checked-out branch (HEAD), read the refs being pushed from stdIn (which Git passes to the pre-push hook). This avoids false positives when pushing a different branch than HEAD or when deleting a branch. Additionally, use case-insensitive comparison for emails to make the check more robust.

  @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;
  }

}
5 changes: 5 additions & 0 deletions script/githooks/pre-push
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 "$@"
5 changes: 3 additions & 2 deletions script/githooks/test/pre_commit_command_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(<String>['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 {
Expand Down
122 changes: 122 additions & 0 deletions script/githooks/test/pre_push_command_test.dart
Comment thread
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Add import 'dart:convert'; to support encoding/decoding standard input streams in the unit tests.

Suggested change
import 'dart:io';
import 'package:githooks/src/pre_push_command.dart';
import 'package:test/test.dart';
import 'dart:convert';
import 'dart:io';
import 'package:githooks/src/pre_push_command.dart';
import 'package:test/test.dart';


void main() {
Comment thread
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Update createCommand to accept and pass stdIn to PrePushCommand, defaulting to an empty stream to prevent hanging in tests.

    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']),
),
);
});

Comment thread
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);
});
});
}
Loading