From 555961b73d1c8cf77992a9fe6cf34c2dced1c1c0 Mon Sep 17 00:00:00 2001 From: Dennis Kugelmann Date: Sat, 15 Aug 2026 13:17:36 +0000 Subject: [PATCH 1/2] feat: workspace matrix command for detecting affected projects based on dif --- lib/src/cli/git_cli.dart | 24 ++ lib/src/command_runner.dart | 1 + lib/src/commands/commands.dart | 1 + .../commands/workspace/commands/commands.dart | 1 + .../commands/workspace/commands/matrix.dart | 112 +++++++++ lib/src/commands/workspace/workspace.dart | 19 ++ .../workspace_matrix/workspace_matrix.dart | 224 ++++++++++++++++++ site/docs/commands/workspace_matrix.md | 52 ++++ test/src/cli/git_cli_test.dart | 62 +++++ test/src/command_runner_test.dart | 15 +- .../workspace/commands/matrix_test.dart | 135 +++++++++++ .../commands/workspace/workspace_test.dart | 39 +++ .../workspace_matrix_test.dart | 138 +++++++++++ 13 files changed, 816 insertions(+), 7 deletions(-) create mode 100644 lib/src/commands/workspace/commands/commands.dart create mode 100644 lib/src/commands/workspace/commands/matrix.dart create mode 100644 lib/src/commands/workspace/workspace.dart create mode 100644 lib/src/workspace_matrix/workspace_matrix.dart create mode 100644 site/docs/commands/workspace_matrix.md create mode 100644 test/src/commands/workspace/commands/matrix_test.dart create mode 100644 test/src/commands/workspace/workspace_test.dart create mode 100644 test/src/workspace_matrix/workspace_matrix_test.dart diff --git a/lib/src/cli/git_cli.dart b/lib/src/cli/git_cli.dart index 21f4d80cd..7dddfaaaa 100644 --- a/lib/src/cli/git_cli.dart +++ b/lib/src/cli/git_cli.dart @@ -20,6 +20,30 @@ Make sure the remote exists and you have the correct access rights.'''; /// Git CLI class Git { + /// Returns file paths changed from [baseRef] to `HEAD`. + /// + /// Paths are relative to [cwd]. + static Future> changedFiles( + String baseRef, { + required Logger logger, + String cwd = '.', + }) async { + final result = await _Cmd.run( + 'git', + ['diff', '--name-only', '--relative', '$baseRef...HEAD'], + logger: logger, + workingDirectory: cwd, + ); + + final lines = result.stdout + .toString() + .split(RegExp(r'\r?\n')) + .map((line) => line.trim()) + .where((line) => line.isNotEmpty) + .toList(); + return lines; + } + /// Determine whether the [remote] is reachable. static Future reachable(Uri remote, {required Logger logger}) async { try { diff --git a/lib/src/command_runner.dart b/lib/src/command_runner.dart index 86d090b4f..786c26466 100644 --- a/lib/src/command_runner.dart +++ b/lib/src/command_runner.dart @@ -38,6 +38,7 @@ class VeryGoodCommandRunner extends CompletionCommandRunner { addCommand(TestCommand(logger: _logger)); addCommand(UpdateCommand(logger: _logger, pubUpdater: pubUpdater)); addCommand(DartCommand(logger: _logger)); + addCommand(WorkspaceCommand(logger: _logger)); addCommand(MCPCommand()); } diff --git a/lib/src/commands/commands.dart b/lib/src/commands/commands.dart index 86e5367a2..abd8a4a0a 100644 --- a/lib/src/commands/commands.dart +++ b/lib/src/commands/commands.dart @@ -4,3 +4,4 @@ export 'dart/dart.dart'; export 'packages/packages.dart'; export 'test/test.dart'; export 'update.dart'; +export 'workspace/workspace.dart'; diff --git a/lib/src/commands/workspace/commands/commands.dart b/lib/src/commands/workspace/commands/commands.dart new file mode 100644 index 000000000..c1f9a99b1 --- /dev/null +++ b/lib/src/commands/workspace/commands/commands.dart @@ -0,0 +1 @@ +export 'matrix.dart'; diff --git a/lib/src/commands/workspace/commands/matrix.dart b/lib/src/commands/workspace/commands/matrix.dart new file mode 100644 index 000000000..d2c58a36f --- /dev/null +++ b/lib/src/commands/workspace/commands/matrix.dart @@ -0,0 +1,112 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:args/command_runner.dart'; +import 'package:mason/mason.dart'; +import 'package:meta/meta.dart'; +import 'package:universal_io/io.dart'; +import 'package:very_good_cli/src/cli/cli.dart'; +import 'package:very_good_cli/src/workspace_matrix/workspace_matrix.dart'; + +/// Signature for resolving files changed against a git base ref. +typedef GitChangedFiles = + Future> Function({ + required String baseRef, + required String cwd, + required Logger logger, + }); + +/// Signature for resolving affected workspace projects. +typedef ResolveAffectedWorkspaceProjects = + List Function({ + required Directory rootDirectory, + required List changedFiles, + }); + +/// {@template workspace_matrix_command} +/// `very_good workspace matrix` command. +/// {@endtemplate} +class WorkspaceMatrixCommand extends Command { + /// {@macro workspace_matrix_command} + WorkspaceMatrixCommand({ + required this.logger, + GitChangedFiles? gitChangedFiles, + ResolveAffectedWorkspaceProjects? resolveAffectedProjects, + }) : _gitChangedFiles = gitChangedFiles ?? _defaultGitChangedFiles, + _resolveAffectedProjects = + resolveAffectedProjects ?? resolveAffectedWorkspaceProjects { + argParser.addOption( + 'base', + help: 'Base git ref used for diffing changes (for example, origin/main).', + valueHelp: 'ref', + ); + } + + /// Logger used by this command. + final Logger logger; + final GitChangedFiles _gitChangedFiles; + final ResolveAffectedWorkspaceProjects _resolveAffectedProjects; + + /// [ArgResults] which can be overridden for testing. + @visibleForTesting + ArgResults? argResultOverrides; + + ArgResults get _argResults => argResultOverrides ?? argResults!; + + @override + String get description => + 'Return a JSON list of affected projects in the current workspace.'; + + @override + String get invocation => 'very_good workspace matrix [arguments]'; + + @override + String get name => 'matrix'; + + @override + Future run() async { + if (_argResults.rest.isNotEmpty) { + usageException('This command does not accept positional arguments.'); + } + + final baseRef = (_argResults['base'] as String?)?.trim(); + if (baseRef == null || baseRef.isEmpty) { + usageException('Missing required option: --base .'); + } + + try { + final cwd = Directory.current.path; + final changedFiles = await _gitChangedFiles( + baseRef: baseRef, + cwd: cwd, + logger: logger, + ); + + final affectedProjects = _resolveAffectedProjects( + rootDirectory: Directory(cwd), + changedFiles: changedFiles, + ); + + logger.info( + jsonEncode( + affectedProjects.map((project) => project.toJson()).toList(), + ), + ); + return ExitCode.success.code; + } on UsageException { + rethrow; + } on Exception catch (error) { + logger.err('$error'); + return ExitCode.unavailable.code; + } + } +} + +Future> _defaultGitChangedFiles({ + required String baseRef, + required String cwd, + required Logger logger, +}) { + return Git.changedFiles(baseRef, cwd: cwd, logger: logger); +} diff --git a/lib/src/commands/workspace/workspace.dart b/lib/src/commands/workspace/workspace.dart new file mode 100644 index 000000000..80eb54141 --- /dev/null +++ b/lib/src/commands/workspace/workspace.dart @@ -0,0 +1,19 @@ +import 'package:args/command_runner.dart'; +import 'package:mason/mason.dart'; +import 'package:very_good_cli/src/commands/workspace/commands/commands.dart'; + +/// {@template workspace_command} +/// `very_good workspace` command for managing Pub workspaces. +/// {@endtemplate} +class WorkspaceCommand extends Command { + /// {@macro workspace_command} + WorkspaceCommand({required Logger logger}) { + addSubcommand(WorkspaceMatrixCommand(logger: logger)); + } + + @override + String get description => 'Command for managing Pub workspaces.'; + + @override + String get name => 'workspace'; +} diff --git a/lib/src/workspace_matrix/workspace_matrix.dart b/lib/src/workspace_matrix/workspace_matrix.dart new file mode 100644 index 000000000..01ee1a720 --- /dev/null +++ b/lib/src/workspace_matrix/workspace_matrix.dart @@ -0,0 +1,224 @@ +import 'dart:io'; + +import 'package:meta/meta.dart'; +import 'package:path/path.dart' as path; +import 'package:very_good_cli/src/pubspec/pubspec.dart'; + +/// A workspace project that should have its tests re-run. +@immutable +class WorkspaceProject { + /// Creates a [WorkspaceProject]. + const WorkspaceProject({required this.name, required this.path}); + + /// Project name from `pubspec.yaml`. + final String name; + + /// Project path relative to the current directory. + final String path; + + /// Converts this project to JSON. + Map toJson() => {'name': name, 'path': path}; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is WorkspaceProject && other.name == name && other.path == path; + } + + @override + int get hashCode => Object.hash(name, path); +} + +/// Resolves affected projects in [rootDirectory] from [changedFiles]. +/// +/// A project is affected when: +/// - files in the project changed directly, or +/// - it depends (directly or transitively) on a changed project via a +/// `path:` dependency listed under `dependencies` or `dev_dependencies`. +List resolveAffectedWorkspaceProjects({ + required Directory rootDirectory, + required List changedFiles, +}) { + final projects = _discoverProjects(rootDirectory); + if (projects.isEmpty || changedFiles.isEmpty) return const []; + + final projectsByPath = { + for (final project in projects) project.absolutePath: project, + }; + + // Ensures that more specific projects are checked first, so that a file + // change in a sub-project is attributed to the sub-project rather than a + // parent project. + // + // Suppose we have the following project structure: + // root/ + // project_a/ + // pubspec.yaml + // project_a/sub_project/ + // pubspec.yaml + // + // If a file in `project_a/sub_project` changes, we want to attribute that + // change to `sub_project` rather than `project_a`. Therefore, we sort the + // projects by the number of path segments in their relative paths, in + // descending order, so that `sub_project` is checked before `project_a`. + final projectsBySpecificity = [...projects] + ..sort( + (a, b) => path + .split(b.relativePath) + .length + .compareTo(path.split(a.relativePath).length), + ); + + final directlyAffected = <_DiscoveredProject>{}; + for (final changedFile in changedFiles) { + final normalized = path.normalize( + path.isAbsolute(changedFile) + ? changedFile + : path.join(rootDirectory.path, changedFile), + ); + + for (final project in projectsBySpecificity) { + final isInProject = + normalized == project.absolutePath || + path.isWithin(project.absolutePath, normalized); + if (isInProject) { + directlyAffected.add(project); + break; + } + } + } + + // When there are no directly affected projects, there is no need to check for + // transitive dependencies. + if (directlyAffected.isEmpty) return const []; + + final dependents = <_DiscoveredProject, Set<_DiscoveredProject>>{ + for (final project in projects) project: <_DiscoveredProject>{}, + }; + + for (final project in projects) { + for (final dependencyPath in project.pathDependencies) { + final dependencyAbsolutePath = path.normalize( + path.join(project.absolutePath, dependencyPath), + ); + final dependencyProject = projectsByPath[dependencyAbsolutePath]; + if (dependencyProject == null || dependencyProject == project) continue; + dependents[dependencyProject]!.add(project); + } + } + + final affected = <_DiscoveredProject>{...directlyAffected}; + final queue = <_DiscoveredProject>[...directlyAffected]; + + while (queue.isNotEmpty) { + final current = queue.removeAt(0); + for (final dependent in dependents[current]!) { + if (!affected.add(dependent)) continue; + queue.add(dependent); + } + } + + final result = + affected + .map( + (project) => WorkspaceProject( + name: project.name, + path: project.relativePath, + ), + ) + .toList() + ..sort((a, b) => a.path.compareTo(b.path)); + + return result; +} + +const _ignoredDirectoryNames = { + '.dart_tool', + '.fvm', + '.git', + '.symlinks', + '.plugin_symlinks', + 'android', + 'build', + 'ios', + 'linux', + 'macos', + 'windows', +}; + +List<_DiscoveredProject> _discoverProjects(Directory rootDirectory) { + final pubspecFiles = []; + + void visit(Directory directory) { + for (final entity in directory.listSync(followLinks: false)) { + if (entity is File && path.basename(entity.path) == 'pubspec.yaml') { + pubspecFiles.add(entity); + continue; + } + + if (entity is! Directory) continue; + final basename = path.basename(entity.path); + if (_ignoredDirectoryNames.contains(basename)) continue; + visit(entity); + } + } + + visit(rootDirectory); + + final projects = <_DiscoveredProject>[]; + for (final pubspecFile in pubspecFiles) { + final pubspec = tryParsePubspec(pubspecFile); + if (pubspec == null) continue; + + final projectDirectory = pubspecFile.parent; + final relativePath = path.relative( + projectDirectory.path, + from: rootDirectory.path, + ); + + projects.add( + _DiscoveredProject( + name: pubspec.name, + absolutePath: path.normalize(projectDirectory.path), + relativePath: _toPosixPath(relativePath), + pathDependencies: _extractPathDependencies(pubspec), + ), + ); + } + + return projects; +} + +List _extractPathDependencies(Pubspec pubspec) { + final pathDependencies = []; + final dependencies = [ + ...pubspec.dependencies.values, + ...pubspec.devDependencies.values, + ]; + + for (final dependency in dependencies) { + if (dependency is! PathDependency) continue; + pathDependencies.add(dependency.path); + } + + return pathDependencies; +} + +String _toPosixPath(String relativePath) { + if (relativePath == '.') return relativePath; + return path.posix.joinAll(path.split(relativePath)); +} + +class _DiscoveredProject { + const _DiscoveredProject({ + required this.name, + required this.absolutePath, + required this.relativePath, + required this.pathDependencies, + }); + + final String name; + final String absolutePath; + final String relativePath; + final List pathDependencies; +} diff --git a/site/docs/commands/workspace_matrix.md b/site/docs/commands/workspace_matrix.md new file mode 100644 index 000000000..9aaf0e954 --- /dev/null +++ b/site/docs/commands/workspace_matrix.md @@ -0,0 +1,52 @@ +--- +sidebar_position: 5 +--- + +# Workspace Matrix 🧭 + +Generate a JSON list of affected projects in the current repository with `very_good workspace matrix`. + +This command is designed for workspace-style repositories and CI matrix workflows. + +## Usage + +```sh +very_good workspace matrix [arguments] +-h, --help Print this usage information. + --base= Base git ref used for diffing changes (for example, origin/main). + +Run "very_good help" to see global options. +``` + +## Output format + +The command writes a JSON array to stdout. Each entry has: + +- `name`: package name from `pubspec.yaml` +- `path`: package path relative to the current directory + +Example output: + +```json +[ + {"name": "core_library_2", "path": "packages/core/core_library_2"}, + {"name": "feature_b", "path": "packages/features/feature_b"} +] +``` + +## How affected projects are resolved + +A project is included when: + +1. Files changed between `--base` and `HEAD` are inside the project directory, or +2. The project depends (directly or transitively) on a changed project through `path:` dependencies. + +Both `dependencies` and `dev_dependencies` are considered. + +## CI example + +```sh +very_good workspace matrix --base origin/main +``` + +You can pass this JSON into a GitHub Actions matrix to run each affected package independently. diff --git a/test/src/cli/git_cli_test.dart b/test/src/cli/git_cli_test.dart index 222a16b7a..9e82f069e 100644 --- a/test/src/cli/git_cli_test.dart +++ b/test/src/cli/git_cli_test.dart @@ -105,6 +105,68 @@ void main() { ); }); + group('changedFiles', () { + test('returns changed file paths', () async { + when( + () => process.run( + any(), + any(), + runInShell: any(named: 'runInShell'), + workingDirectory: any(named: 'workingDirectory'), + ), + ).thenAnswer( + (_) async => ProcessResult( + 42, + ExitCode.success.code, + 'packages/a/lib/a.dart\npackages/b/test/b_test.dart\n', + '', + ), + ); + + late List changedFiles; + await ProcessOverrides.runZoned(() async { + changedFiles = await Git.changedFiles( + 'origin/main', + cwd: '/workspace', + logger: logger, + ); + }, runProcess: process.run); + + expect(changedFiles, [ + 'packages/a/lib/a.dart', + 'packages/b/test/b_test.dart', + ]); + verify( + () => process.run( + 'git', + ['diff', '--name-only', '--relative', 'origin/main...HEAD'], + runInShell: any(named: 'runInShell'), + workingDirectory: '/workspace', + ), + ).called(1); + }); + + test('returns an empty list when git diff output is empty', () async { + when( + () => process.run( + any(), + any(), + runInShell: any(named: 'runInShell'), + workingDirectory: any(named: 'workingDirectory'), + ), + ).thenAnswer( + (_) async => ProcessResult(42, ExitCode.success.code, '', ''), + ); + + late List changedFiles; + await ProcessOverrides.runZoned(() async { + changedFiles = await Git.changedFiles('origin/main', logger: logger); + }, runProcess: process.run); + + expect(changedFiles, isEmpty); + }); + }); + group('UnreachableGitDependency', () { test('has correct toString override', () { final remote = Uri.parse('https://github.com/org/repo'); diff --git a/test/src/command_runner_test.dart b/test/src/command_runner_test.dart index 2550d589c..e07758e63 100644 --- a/test/src/command_runner_test.dart +++ b/test/src/command_runner_test.dart @@ -35,13 +35,14 @@ const expectedUsage = [ ''' --[no-]verbose Noisy logging, including all shell commands executed.\n''', '\n', 'Available commands:\n', - ' create very_good create [arguments]\n', - ''' Creates a new very good project in the specified directory.\n''', - ' dart Command for running dart related commands.\n', - ' mcp Start the MCP (Model Context Protocol) server. WARNING: This is an experimental package and may change or become unstable without notice. Use it with caution at your own risk.\n', - ' packages Command for managing packages.\n', - ' test Run `flutter test` in a project. (Check very_good dart test for running `dart test` instead.)\n', - ' update Update Very Good CLI.\n', + ' create very_good create [arguments]\n', + ''' Creates a new very good project in the specified directory.\n''', + ' dart Command for running dart related commands.\n', + ' mcp Start the MCP (Model Context Protocol) server. WARNING: This is an experimental package and may change or become unstable without notice. Use it with caution at your own risk.\n', + ' packages Command for managing packages.\n', + ' test Run `flutter test` in a project. (Check very_good dart test for running `dart test` instead.)\n', + ' update Update Very Good CLI.\n', + ' workspace Command for managing Pub workspaces.\n', '\n', 'Run "very_good help " for more information about a command.', ]; diff --git a/test/src/commands/workspace/commands/matrix_test.dart b/test/src/commands/workspace/commands/matrix_test.dart new file mode 100644 index 000000000..19a1da84f --- /dev/null +++ b/test/src/commands/workspace/commands/matrix_test.dart @@ -0,0 +1,135 @@ +// Expected usage of the plugin will need to be adjacent strings due to format +// and also be longer than 80 chars. +// ignore_for_file: no_adjacent_strings_in_list + +import 'dart:io'; + +import 'package:mason/mason.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; +import 'package:universal_io/io.dart'; +import 'package:very_good_cli/src/commands/workspace/commands/matrix.dart'; +import 'package:very_good_cli/src/workspace_matrix/workspace_matrix.dart'; + +import '../../../../helpers/command_helper.dart'; + +class _MockLogger extends Mock implements Logger {} + +const _expectedWorkspaceMatrixUsage = [ + 'Return a JSON list of affected projects in the current workspace.\n' + '\n' + 'Usage: very_good workspace matrix [arguments]\n' + '-h, --help Print this usage information.\n' + ' --base= Base git ref used for diffing changes (for example, origin/main).\n' + '\n' + 'Run "very_good help" to see global options.', +]; + +void main() { + group('workspace matrix', () { + test( + 'help', + withRunner((commandRunner, logger, pubUpdater, printLogs) async { + final result = await commandRunner.run([ + 'workspace', + 'matrix', + '--help', + ]); + expect(printLogs, equals(_expectedWorkspaceMatrixUsage)); + expect(result, equals(ExitCode.success.code)); + + printLogs.clear(); + + final resultAbbr = await commandRunner.run([ + 'workspace', + 'matrix', + '-h', + ]); + expect(printLogs, equals(_expectedWorkspaceMatrixUsage)); + expect(resultAbbr, equals(ExitCode.success.code)); + }), + ); + + test( + 'returns usage exit code when --base is missing', + withRunner((commandRunner, logger, pubUpdater, printLogs) async { + final result = await commandRunner.run(['workspace', 'matrix']); + + expect(result, equals(ExitCode.usage.code)); + }), + ); + + test( + 'returns usage exit code when positional arguments are provided', + withRunner((commandRunner, logger, pubUpdater, printLogs) async { + final result = await commandRunner.run([ + 'workspace', + 'matrix', + '--base', + 'origin/main', + 'extra', + ]); + + expect(result, equals(ExitCode.usage.code)); + }), + ); + + test('returns JSON encoded affected projects', () async { + final logger = _MockLogger(); + when(() => logger.info(any())).thenReturn(null); + when(() => logger.err(any())).thenReturn(null); + + final command = WorkspaceMatrixCommand( + logger: logger, + gitChangedFiles: + ({required baseRef, required cwd, required logger}) async => [ + 'packages/core/pubspec.yaml', + ], + resolveAffectedProjects: + ({required rootDirectory, required changedFiles}) { + return changedFiles.contains('packages/core/pubspec.yaml') + ? const [ + WorkspaceProject(name: 'core', path: 'packages/core'), + ] + : const []; + }, + ); + command.argResultOverrides = command.argParser.parse([ + '--base', + 'origin/main', + ]); + + final result = await command.run(); + + expect(result, equals(ExitCode.success.code)); + verify( + () => logger.info('[{"name":"core","path":"packages/core"}]'), + ).called(1); + }); + + test('returns unavailable when git diff fails', () async { + final logger = _MockLogger(); + when(() => logger.info(any())).thenReturn(null); + when(() => logger.err(any())).thenReturn(null); + + final command = WorkspaceMatrixCommand( + logger: logger, + gitChangedFiles: + ({required baseRef, required cwd, required logger}) async { + throw const ProcessException('git', ['diff']); + }, + ); + command.argResultOverrides = command.argParser.parse([ + '--base', + 'origin/main', + ]); + + final result = await command.run(); + + expect(result, equals(ExitCode.unavailable.code)); + verify( + () => logger.err(any(that: contains('ProcessException'))), + ).called(1); + }); + }); +} diff --git a/test/src/commands/workspace/workspace_test.dart b/test/src/commands/workspace/workspace_test.dart new file mode 100644 index 000000000..f436c0723 --- /dev/null +++ b/test/src/commands/workspace/workspace_test.dart @@ -0,0 +1,39 @@ +// Expected usage of the plugin will need to be adjacent strings due to format +// and also be longer than 80 chars. +// ignore_for_file: no_adjacent_strings_in_list, lines_longer_than_80_chars + +import 'package:mason/mason.dart'; +import 'package:test/test.dart'; + +import '../../../helpers/command_helper.dart'; + +const _expectedWorkspaceUsage = [ + 'Command for managing Pub workspaces.\n' + '\n' + 'Usage: very_good workspace [arguments]\n' + '-h, --help Print this usage information.\n' + '\n' + 'Available subcommands:\n' + ' matrix Return a JSON list of affected projects in the current workspace.\n' + '\n' + 'Run "very_good help" to see global options.', +]; + +void main() { + group('workspace', () { + test( + 'help', + withRunner((commandRunner, logger, pubUpdater, printLogs) async { + final result = await commandRunner.run(['workspace', '--help']); + expect(printLogs, equals(_expectedWorkspaceUsage)); + expect(result, equals(ExitCode.success.code)); + + printLogs.clear(); + + final resultAbbr = await commandRunner.run(['workspace', '-h']); + expect(printLogs, equals(_expectedWorkspaceUsage)); + expect(resultAbbr, equals(ExitCode.success.code)); + }), + ); + }); +} diff --git a/test/src/workspace_matrix/workspace_matrix_test.dart b/test/src/workspace_matrix/workspace_matrix_test.dart new file mode 100644 index 000000000..052fbef8c --- /dev/null +++ b/test/src/workspace_matrix/workspace_matrix_test.dart @@ -0,0 +1,138 @@ +import 'dart:io'; + +import 'package:path/path.dart' as path; +import 'package:test/test.dart'; +import 'package:very_good_cli/src/workspace_matrix/workspace_matrix.dart'; + +void main() { + Directory writePubspec(Directory root, String projectPath, String content) { + final directory = Directory(path.join(root.path, projectPath)) + ..createSync(recursive: true); + File(path.join(directory.path, 'pubspec.yaml')).writeAsStringSync(content); + return directory; + } + + group('resolveAffectedWorkspaceProjects', () { + late Directory tempDirectory; + + setUp(() { + tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + }); + + test('returns empty when no pubspec projects are discovered', () { + final result = resolveAffectedWorkspaceProjects( + rootDirectory: tempDirectory, + changedFiles: const ['lib/main.dart'], + ); + + expect(result, isEmpty); + }); + + test('returns directly changed projects', () { + writePubspec(tempDirectory, 'packages/a', ''' +name: a +environment: + sdk: ^3.9.0 +'''); + writePubspec(tempDirectory, 'packages/b', ''' +name: b +environment: + sdk: ^3.9.0 +'''); + + final result = resolveAffectedWorkspaceProjects( + rootDirectory: tempDirectory, + changedFiles: const ['packages/b/lib/b.dart'], + ); + + expect(result, const [WorkspaceProject(name: 'b', path: 'packages/b')]); + }); + + test('propagates affected projects through path dependencies', () { + writePubspec(tempDirectory, 'packages/core/core_library_1', ''' +name: core_library_1 +environment: + sdk: ^3.9.0 +'''); + writePubspec(tempDirectory, 'packages/core/core_library_2', ''' +name: core_library_2 +environment: + sdk: ^3.9.0 +'''); + writePubspec(tempDirectory, 'packages/features/feature_a', ''' +name: feature_b +environment: + sdk: ^3.9.0 +dependencies: + core_library_1: + path: ../../core/core_library_1 +'''); + writePubspec(tempDirectory, 'packages/features/feature_b', ''' +name: feature_b +environment: + sdk: ^3.9.0 +dependencies: + core_library_2: + path: ../../core/core_library_2 +'''); + writePubspec(tempDirectory, 'packages/features/feature_c', ''' +name: feature_c +environment: + sdk: ^3.9.0 +dev_dependencies: + feature_b: + path: ../feature_b +'''); + + final result = resolveAffectedWorkspaceProjects( + rootDirectory: tempDirectory, + changedFiles: const [ + 'packages/core/core_library_2/lib/src/change.dart', + ], + ); + + expect(result, const [ + WorkspaceProject( + name: 'core_library_2', + path: 'packages/core/core_library_2', + ), + WorkspaceProject( + name: 'feature_b', + path: 'packages/features/feature_b', + ), + WorkspaceProject( + name: 'feature_c', + path: 'packages/features/feature_c', + ), + ]); + }); + + test('maps a changed file to the deepest matching project', () { + writePubspec(tempDirectory, '.', ''' +name: app +environment: + sdk: ^3.9.0 +'''); + writePubspec(tempDirectory, 'packages/core', ''' +name: core +environment: + sdk: ^3.9.0 +'''); + + final childChange = resolveAffectedWorkspaceProjects( + rootDirectory: tempDirectory, + changedFiles: const ['packages/core/lib/core.dart'], + ); + final rootChange = resolveAffectedWorkspaceProjects( + rootDirectory: tempDirectory, + changedFiles: const ['lib/main.dart'], + ); + + expect(childChange, const [ + WorkspaceProject(name: 'core', path: 'packages/core'), + ]); + expect(rootChange, const [WorkspaceProject(name: 'app', path: '.')]); + }); + }); +} From 74b614d2cab9906f04ba63e7d5daf1f7a2c9ebd9 Mon Sep 17 00:00:00 2001 From: Dennis Kugelmann Date: Sat, 15 Aug 2026 14:58:35 +0000 Subject: [PATCH 2/2] fix: doesn't install auto complete in CI environments --- lib/src/command_runner.dart | 3 +++ test/src/command_runner_test.dart | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/lib/src/command_runner.dart b/lib/src/command_runner.dart index 786c26466..af8a32346 100644 --- a/lib/src/command_runner.dart +++ b/lib/src/command_runner.dart @@ -58,6 +58,9 @@ class VeryGoodCommandRunner extends CompletionCommandRunner { bool? isWindowsOverride; bool get _isWindows => isWindowsOverride ?? Platform.isWindows; + @override + bool get enableAutoInstall => !environment.containsKey('CI'); + @override void printUsage() => _logger.info(usage); diff --git a/test/src/command_runner_test.dart b/test/src/command_runner_test.dart index e07758e63..f71a74a72 100644 --- a/test/src/command_runner_test.dart +++ b/test/src/command_runner_test.dart @@ -81,6 +81,20 @@ void main() { expect(VeryGoodCommandRunner.new, returnsNormally); }); + test('disables completion auto-install in CI', () { + expect(commandRunner.enableAutoInstall, isFalse); + }); + + test('enables completion auto-install outside CI', () { + final localRunner = VeryGoodCommandRunner( + logger: logger, + pubUpdater: pubUpdater, + environment: const {}, + ); + + expect(localRunner.enableAutoInstall, isTrue); + }); + group('run', () { test('shows update message when newer version exists', () async { when(