diff --git a/README.md b/README.md index 2cd4339..2c9c062 100644 --- a/README.md +++ b/README.md @@ -260,8 +260,13 @@ mtrust_api_guard changelog --regenerate --tag-prefix= Prefix for version tags (defaults to "v") --package= Package name for workspace tags (package/vX.Y.Z) + --concurrency= + Number of refs to analyze in parallel when regenerating + (defaults to "4") ``` +When regenerating, the API documentation for each tag is analyzed in parallel (bounded by `--concurrency`). The first tagged release is diffed against the repository's root commit; if that commit has no analyzable Dart project (e.g. an empty initial commit), the API diff for that release is skipped and only its commit summary is shown. + ## Version Detects the API changes that occured and creates a changelog, version bump, version badge and tag automatically. diff --git a/lib/changelog_generator/changelog_generator.dart b/lib/changelog_generator/changelog_generator.dart index 32dc91d..597b0db 100644 --- a/lib/changelog_generator/changelog_generator.dart +++ b/lib/changelog_generator/changelog_generator.dart @@ -4,8 +4,10 @@ import 'package:conventional/conventional.dart'; import 'package:mtrust_api_guard/doc_comparator/api_change.dart'; import 'package:mtrust_api_guard/doc_comparator/api_change_formatter.dart'; import 'package:mtrust_api_guard/doc_comparator/doc_comparator.dart'; +import 'package:mtrust_api_guard/doc_comparator/get_ref.dart'; import 'package:mtrust_api_guard/doc_generator/git_utils.dart'; import 'package:mtrust_api_guard/logger.dart'; +import 'package:mtrust_api_guard/models/package_info.dart'; import 'package:path/path.dart'; import 'package:pub_semver/pub_semver.dart'; import 'package:yaml/yaml.dart'; @@ -60,11 +62,19 @@ class ChangelogGenerator { } /// Regenerates the entire CHANGELOG.md by walking version tags and comparing each release. + /// + /// The API documentation for each unique ref is analyzed up front, in parallel + /// (bounded by [concurrency]), since that is the expensive part. The per-release + /// diffs are then computed from the in-memory results. Refs that cannot be + /// analyzed (e.g. an empty initial commit with no pubspec.yaml) are skipped + /// gracefully: the affected release still gets its commit-based entry, just + /// without an API Changes section. Future regenerateFullChangelog({ required Directory gitRoot, required bool cache, String tagPrefix = 'v', String? packageName, + int concurrency = 4, }) async { final tags = packageName != null ? await GitUtils.getVersionsForPackage(gitRoot.path, packageName, tagPrefix: tagPrefix) @@ -75,34 +85,57 @@ class ChangelogGenerator { } final rootCommit = await GitUtils.getRootCommit(gitRoot.path); - final releasePairs = <({String tag, Version version, String baseRef})>[]; + final releasePairs = <({String tag, Version version, String? baseRef})>[]; String? previousTag; for (final (tag, version) in tags) { + // The very first release has no previous tag. We fall back to the repo + // root commit, but it may be an empty initial commit without a Dart + // project, in which case the diff is skipped further down. final compareBase = previousTag ?? rootCommit; - if (compareBase == null) { - throw Exception('Could not determine base ref for first release $tag'); - } releasePairs.add((tag: tag, version: version, baseRef: compareBase)); previousTag = tag; } - final buffer = StringBuffer(); final pubspecInfo = await _getPubspecInfo(); final pubspecVersion = Version.parse(pubspecInfo['version']!); final latestTaggedVersion = tags.last.$2; + final latestTag = tags.last.$1; + final hasUnreleased = pubspecVersion > latestTaggedVersion; + + // Analyze all unique cacheable refs (tags + base commits) in parallel. + // HEAD is handled separately because it is analyzed from the working + // directory and is never cached. + final cacheableRefs = {}; + for (final release in releasePairs) { + if (release.baseRef != null) cacheableRefs.add(release.baseRef!); + cacheableRefs.add(release.tag); + } + + final apisByRef = await _analyzeRefsInParallel( + refs: cacheableRefs, + gitRoot: gitRoot, + cache: cache, + concurrency: concurrency, + ); + + PackageApi? headApi; + if (hasUnreleased) { + headApi = await _tryAnalyzeRef('HEAD', gitRoot: gitRoot, cache: cache); + } + + final buffer = StringBuffer(); - if (pubspecVersion > latestTaggedVersion) { - final latestTag = tags.last.$1; + if (hasUnreleased) { logger.info('Regenerating unreleased section ($latestTag..HEAD)'); - final unreleasedChanges = await compare( - baseRef: latestTag, - newRef: 'HEAD', - dartRoot: projectRoot, - gitRoot: gitRoot, - cache: cache, - ); + final baseApi = apisByRef[latestTag]; + final unreleasedChanges = (baseApi != null && headApi != null) + ? compareApis(baseApi, headApi) + : []; + if (baseApi == null || headApi == null) { + logger.warn('Skipping API diff for unreleased section: could not analyze $latestTag or HEAD'); + } final unreleasedCommits = await GitUtils.getCommits( root: gitRoot.path, @@ -122,15 +155,22 @@ class ChangelogGenerator { } for (final release in releasePairs.reversed) { - logger.info('Regenerating changelog for ${release.version} (${release.baseRef}..${release.tag})'); - - final changes = await compare( - baseRef: release.baseRef, - newRef: release.tag, - dartRoot: projectRoot, - gitRoot: gitRoot, - cache: cache, - ); + logger.info('Regenerating changelog for ${release.version} (${release.baseRef ?? 'initial'}..${release.tag})'); + + final baseApi = release.baseRef != null ? apisByRef[release.baseRef] : null; + final newApi = apisByRef[release.tag]; + + List changes; + if (baseApi != null && newApi != null) { + changes = compareApis(baseApi, newApi); + } else { + changes = []; + final missing = newApi == null ? release.tag : (release.baseRef ?? 'initial commit'); + logger.warn( + 'Skipping API diff for ${release.version}: could not analyze $missing ' + '(e.g. missing pubspec.yaml at that ref)', + ); + } final commits = await GitUtils.getCommits( root: gitRoot.path, @@ -155,6 +195,71 @@ class ChangelogGenerator { return buffer.toString(); } + /// Analyzes the API documentation for each ref, deduplicating by resolved + /// commit hash and running up to [concurrency] analyses at a time. + /// + /// Returns a map of every input ref to its [PackageApi], or `null` when the + /// ref could not be analyzed. + Future> _analyzeRefsInParallel({ + required Set refs, + required Directory gitRoot, + required bool cache, + required int concurrency, + }) async { + // Deduplicate by resolved commit hash. Distinct refs that point at the same + // commit must not be analyzed concurrently, since they would race on the + // same worktree path. + final refToHash = {}; + for (final ref in refs) { + try { + refToHash[ref] = await GitUtils.resolveRef(ref, gitRoot.path); + } catch (e) { + logger.warn('Could not resolve ref $ref: $e'); + } + } + + final representativeRefByHash = {}; + for (final entry in refToHash.entries) { + representativeRefByHash.putIfAbsent(entry.value, () => entry.key); + } + + final uniqueRefs = representativeRefByHash.values.toList(); + final batchSize = concurrency < 1 ? 1 : concurrency; + final apiByHash = {}; + + for (var i = 0; i < uniqueRefs.length; i += batchSize) { + final batch = uniqueRefs.skip(i).take(batchSize).toList(); + final results = await Future.wait( + batch.map((ref) async { + final api = await _tryAnalyzeRef(ref, gitRoot: gitRoot, cache: cache); + return MapEntry(refToHash[ref]!, api); + }), + ); + for (final entry in results) { + apiByHash[entry.key] = entry.value; + } + } + + final result = {}; + for (final entry in refToHash.entries) { + result[entry.key] = apiByHash[entry.value]; + } + return result; + } + + Future _tryAnalyzeRef( + String ref, { + required Directory gitRoot, + required bool cache, + }) async { + try { + return await getRef(ref: ref, dartRoot: projectRoot, gitRoot: gitRoot, cache: cache); + } catch (e) { + logger.warn('Failed to analyze ref $ref: $e'); + return null; + } + } + /// Updates the CHANGELOG.md file with the new entry at the top Future updateChangelogFile() async { final changelogFile = File(join(projectRoot.path, 'CHANGELOG.md')); @@ -184,6 +289,7 @@ class ChangelogGenerator { required bool cache, String tagPrefix = 'v', String? packageName, + int concurrency = 4, }) async { final changelogFile = File(join(projectRoot.path, 'CHANGELOG.md')); final content = await regenerateFullChangelog( @@ -191,6 +297,7 @@ class ChangelogGenerator { cache: cache, tagPrefix: tagPrefix, packageName: packageName, + concurrency: concurrency, ); await changelogFile.writeAsString(content); logger.info('${changelogFile.absolute.path} regenerated successfully.'); diff --git a/lib/changelog_generator/changelog_generator_command.dart b/lib/changelog_generator/changelog_generator_command.dart index 1482742..b4e0c90 100644 --- a/lib/changelog_generator/changelog_generator_command.dart +++ b/lib/changelog_generator/changelog_generator_command.dart @@ -46,6 +46,12 @@ class ChangelogGeneratorCommand extends Command 'package', help: 'Package name for workspace tags in the format package/vX.Y.Z', valueHelp: 'name', + ) + ..addOption( + 'concurrency', + help: 'Number of refs to analyze in parallel when regenerating', + defaultsTo: '4', + valueHelp: 'count', ); } @@ -65,6 +71,10 @@ class ChangelogGeneratorCommand extends Command return argResults?['package'] as String?; } + int get concurrency { + return int.tryParse(argResults?['concurrency'] as String? ?? '4') ?? 4; + } + @override FutureOr? run() async { final gitRoot = Directory.current; @@ -82,6 +92,7 @@ class ChangelogGeneratorCommand extends Command cache: cache, tagPrefix: tagPrefix, packageName: packageName, + concurrency: concurrency, ); } else { final changelog = await changelogGenerator.regenerateFullChangelog( @@ -89,6 +100,7 @@ class ChangelogGeneratorCommand extends Command cache: cache, tagPrefix: tagPrefix, packageName: packageName, + concurrency: concurrency, ); print('\n$changelog'); } diff --git a/lib/doc_comparator/doc_comparator.dart b/lib/doc_comparator/doc_comparator.dart index 2a5e3b1..7cfd4f6 100644 --- a/lib/doc_comparator/doc_comparator.dart +++ b/lib/doc_comparator/doc_comparator.dart @@ -20,6 +20,11 @@ Future> compare({ final baseApi = await getRef(ref: baseRef, dartRoot: dartRoot, gitRoot: gitRoot, cache: cache); final newApi = await getRef(ref: newRef, dartRoot: dartRoot, gitRoot: gitRoot, cache: cache); + return compareApis(baseApi, newApi); +} + +/// Computes the API changes between two already-resolved package APIs. +List compareApis(PackageApi baseApi, PackageApi newApi) { final apiChanges = baseApi.components.compareTo(newApi.components); apiChanges.addAll(baseApi.metadata.compareTo(newApi.metadata)); diff --git a/test/commands/changelog_command_test.dart b/test/commands/changelog_command_test.dart index 1afd152..4529fae 100644 --- a/test/commands/changelog_command_test.dart +++ b/test/commands/changelog_command_test.dart @@ -63,6 +63,33 @@ void main() { expect(changelog, contains('Released on:')); }, timeout: const Timeout(Duration(minutes: 3))); + test('regenerate tolerates an empty initial commit with no Dart project', () async { + await testSetup.setupGitRepo(); + + // Empty initial commit without a pubspec.yaml, mirroring the broken + // first-commit scenario that previously crashed regeneration. + await runProcess('git', ['commit', '--allow-empty', '-m', 'chore: init'], workingDir: testSetup.tempDir.path); + + await testSetup.setupFlutterPackage(); + await copyDir(testSetup.fixtures.appV100Dir, testSetup.tempDir); + await testSetup.commitChanges('chore!: Initial release v${TestConstants.initialVersion}'); + await runProcess('git', ['tag', 'v${TestConstants.initialVersion}'], workingDir: testSetup.tempDir.path); + + await copyDir(testSetup.fixtures.appV101Dir, testSetup.tempDir); + testSetup.setVersion(TestConstants.patchVersion); + await testSetup.commitChanges('fix: API changes for v${TestConstants.patchVersion}'); + await runProcess('git', ['tag', 'v${TestConstants.patchVersion}'], workingDir: testSetup.tempDir.path); + + await testSetup.runApiGuard('changelog', ['--regenerate']); + + final changelog = await File(p.join(testSetup.tempDir.path, 'CHANGELOG.md')).readAsString(); + printOnFailure('Regenerated changelog:\n$changelog'); + + final versionHeaders = RegExp(r'^## (.+)$', multiLine: true).allMatches(changelog).map((m) => m.group(1)).toList(); + expect(versionHeaders, contains(TestConstants.initialVersion)); + expect(versionHeaders, contains(TestConstants.patchVersion)); + }, timeout: const Timeout(Duration(minutes: 3))); + test('regenerate includes unreleased section when pubspec is ahead of latest tag', () async { await testSetup.setupGitRepo(); await testSetup.setupFlutterPackage();