blob: 316251d4f01ac0d8b6904e3b3fc1f38bc3f0ab5f [file] [log] [blame]
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:file/file.dart';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as p;
import 'git_version_finder.dart';
import 'repository_package.dart';
/// The state of a package on disk relative to git state.
@immutable
class PackageChangeState {
/// Creates a new immutable state instance.
const PackageChangeState({
required this.hasChanges,
required this.hasChangelogChange,
required this.needsChangelogChange,
required this.needsVersionChange,
});
/// True if there are any changes to files in the package.
final bool hasChanges;
/// True if the package's CHANGELOG.md has been changed.
final bool hasChangelogChange;
/// True if any changes in the package require a version change according
/// to repository policy.
final bool needsVersionChange;
/// True if any changes in the package require a CHANGELOG change according
/// to repository policy.
final bool needsChangelogChange;
}
/// Checks [package] against [changedPaths] to determine what changes it has
/// and how those changes relate to repository policy about CHANGELOG and
/// version updates.
///
/// [changedPaths] should be a list of POSIX-style paths from a common root,
/// and [relativePackagePath] should be the path to [package] from that same
/// root. Commonly these will come from `gitVersionFinder.getChangedFiles()`
/// and `getRelativePosixPath(package.directory, gitDir.path)` respectively;
/// they are arguments mainly to allow for caching the changed paths for an
/// entire command run.
///
/// If [git] is provided, [changedPaths] must be repository-relative
/// paths, and change type detection can use file diffs in addition to paths.
Future<PackageChangeState> checkPackageChangeState(
RepositoryPackage package, {
required List<String> changedPaths,
required String relativePackagePath,
GitVersionFinder? git,
}) async {
final String packagePrefix = relativePackagePath.endsWith('/')
? relativePackagePath
: '$relativePackagePath/';
bool hasChanges = false;
bool hasChangelogChange = false;
bool needsVersionChange = false;
bool needsChangelogChange = false;
for (final String path in changedPaths) {
// Only consider files within the package.
if (!path.startsWith(packagePrefix)) {
continue;
}
final String packageRelativePath = path.substring(packagePrefix.length);
hasChanges = true;
final List<String> components = p.posix.split(packageRelativePath);
if (components.isEmpty) {
continue;
}
if (components.first == 'CHANGELOG.md') {
hasChangelogChange = true;
continue;
}
if (!needsVersionChange) {
// Developer-only changes don't need version changes or changelog changes.
if (await _isDevChange(components, git: git, repoPath: path)) {
continue;
}
final bool isUnpublishedExampleChange =
_isUnpublishedExampleChange(components, package);
// Since examples of federated plugin implementations are only intended
// for testing purposes, any unpublished example change in one of them is
// effectively a developer-only change.
if (package.isFederated &&
package.isPlatformImplementation &&
isUnpublishedExampleChange) {
continue;
}
// Anything that is not developer-only might benefit from changelog
// changes. This errs on the side of flagging, so that someone checks to
// see if it should be mentioned there or not.
needsChangelogChange = true;
// Most changes that aren't developer-only need version changes.
if (
// One of a few special files example will be shown on pub.dev, but
// for anything else in the example, publishing isn't necessary (even
// if it is relevant to mention in the CHANGELOG for the future).
!isUnpublishedExampleChange) {
needsVersionChange = true;
}
}
}
return PackageChangeState(
hasChanges: hasChanges,
hasChangelogChange: hasChangelogChange,
needsChangelogChange: needsChangelogChange,
needsVersionChange: needsVersionChange);
}
bool _isTestChange(List<String> pathComponents) {
return pathComponents.contains('test') ||
pathComponents.contains('integration_test') ||
pathComponents.contains('androidTest') ||
pathComponents.contains('RunnerTests') ||
pathComponents.contains('RunnerUITests') ||
pathComponents.contains('Tests') ||
pathComponents.last == 'dart_test.yaml' ||
// Pigeon's custom platform tests.
pathComponents.first == 'platform_tests';
}
// True if the given file is an example file other than the one that will be
// published according to https://dart.dev/tools/pub/package-layout#examples.
//
// This is not exhastive; it currently only handles variations we actually have
// in our repositories.
bool _isUnpublishedExampleChange(
List<String> pathComponents, RepositoryPackage package) {
if (pathComponents.first != 'example') {
return false;
}
final List<String> exampleComponents = pathComponents.sublist(1);
if (exampleComponents.isEmpty) {
return false;
}
final Directory exampleDirectory =
package.directory.childDirectory('example');
// Check for example.md/EXAMPLE.md first, as that has priority. If it's
// present, any other example file is unpublished.
final bool hasExampleMd =
exampleDirectory.childFile('example.md').existsSync() ||
exampleDirectory.childFile('EXAMPLE.md').existsSync();
if (hasExampleMd) {
return !(exampleComponents.length == 1 &&
exampleComponents.first.toLowerCase() == 'example.md');
}
// Most packages have an example/lib/main.dart (or occasionally
// example/main.dart), so check for that. The other naming variations aren't
// currently used.
const String mainName = 'main.dart';
final bool hasExampleCode =
exampleDirectory.childDirectory('lib').childFile(mainName).existsSync() ||
exampleDirectory.childFile(mainName).existsSync();
if (hasExampleCode) {
// If there is an example main, only that example file is published.
return !((exampleComponents.length == 1 &&
exampleComponents.first == mainName) ||
(exampleComponents.length == 2 &&
exampleComponents.first == 'lib' &&
exampleComponents[1] == mainName));
}
// If there's no example code either, the example README.md, if any, is the
// file that will be published.
return exampleComponents.first.toLowerCase() != 'readme.md';
}
// True if the change is only relevant to people working on the package.
Future<bool> _isDevChange(List<String> pathComponents,
{GitVersionFinder? git, String? repoPath}) async {
return _isTestChange(pathComponents) ||
// The top-level "tool" directory is for non-client-facing utility
// code, such as test scripts.
pathComponents.first == 'tool' ||
// The top-level "pigeons" directory is the repo convention for storing
// pigeon input files.
pathComponents.first == 'pigeons' ||
// Entry point for the 'custom-test' command, which is only for CI and
// local testing.
pathComponents.first == 'run_tests.sh' ||
// CONTRIBUTING.md is dev-facing.
pathComponents.last == 'CONTRIBUTING.md' ||
// Lints don't affect clients.
pathComponents.contains('analysis_options.yaml') ||
pathComponents.contains('lint-baseline.xml') ||
// Example build files are very unlikely to be interesting to clients.
_isExampleBuildFile(pathComponents) ||
// Test-only gradle depenedencies don't affect clients.
await _isGradleTestDependencyChange(pathComponents,
git: git, repoPath: repoPath) ||
// Implementation comments don't affect clients.
// This check is currently Dart-only since that's the only place
// this has come up in practice; it could be generalized to other
// languages if needed.
await _isDartImplementationCommentChange(pathComponents,
git: git, repoPath: repoPath);
}
bool _isExampleBuildFile(List<String> pathComponents) {
if (!pathComponents.contains('example')) {
return false;
}
return pathComponents.contains('gradle-wrapper.properties') ||
pathComponents.contains('gradle.properties') ||
pathComponents.contains('build.gradle') ||
pathComponents.contains('Runner.xcodeproj') ||
pathComponents.contains('Runner.xcscheme') ||
pathComponents.contains('Runner.xcworkspace') ||
pathComponents.contains('Podfile') ||
pathComponents.contains('CMakeLists.txt') ||
pathComponents.contains('.pluginToolsConfig.yaml') ||
pathComponents.contains('pubspec.yaml');
}
Future<bool> _isGradleTestDependencyChange(List<String> pathComponents,
{GitVersionFinder? git, String? repoPath}) async {
if (git == null) {
return false;
}
if (pathComponents.last != 'build.gradle') {
return false;
}
final List<String> diff = await git.getDiffContents(targetPath: repoPath);
final RegExp changeLine = RegExp(r'^[+-] ');
final RegExp testDependencyLine =
RegExp(r'^[+-]\s*(?:androidT|t)estImplementation\s');
bool foundTestDependencyChange = false;
for (final String line in diff) {
if (!changeLine.hasMatch(line) ||
line.startsWith('--- ') ||
line.startsWith('+++ ')) {
continue;
}
if (!testDependencyLine.hasMatch(line)) {
return false;
}
foundTestDependencyChange = true;
}
// Only return true if a test dependency change was found, as a failsafe
// against having the wrong (e.g., incorrectly empty) diff output.
return foundTestDependencyChange;
}
// Returns true if the given file is a Dart file whose only changes are
// implementation comments (i.e., not doc comments).
Future<bool> _isDartImplementationCommentChange(List<String> pathComponents,
{GitVersionFinder? git, String? repoPath}) async {
if (git == null) {
return false;
}
if (!pathComponents.last.endsWith('.dart')) {
return false;
}
final List<String> diff = await git.getDiffContents(targetPath: repoPath);
final RegExp changeLine = RegExp(r'^[+-] ');
final RegExp whitespaceLine = RegExp(r'^[+-]\s*$');
final RegExp nonDocCommentLine = RegExp(r'^[+-]\s*//\s');
bool foundIgnoredChange = false;
for (final String line in diff) {
if (!changeLine.hasMatch(line) ||
line.startsWith('--- ') ||
line.startsWith('+++ ')) {
continue;
}
if (!nonDocCommentLine.hasMatch(line) && !whitespaceLine.hasMatch(line)) {
return false;
}
foundIgnoredChange = true;
}
// Only return true if an ignored change was found, as a failsafe against
// having the wrong (e.g., incorrectly empty) diff output.
return foundIgnoredChange;
}