diff --git a/lib/src/command/add.dart b/lib/src/command/add.dart index f5f8d2268..7c6c701dc 100644 --- a/lib/src/command/add.dart +++ b/lib/src/command/add.dart @@ -117,6 +117,11 @@ For example (follow the same format including spaces): help: 'Path of git package in repository', hide: true, ); + argParser.addOption( + 'git-tag-pattern', + help: 'The tag-pattern to search for versions in repository', + hide: true, + ); argParser.addOption( 'hosted-url', help: 'URL of package host server', @@ -543,6 +548,11 @@ Specify multiple sdk packages with descriptors.'''); if (gitUrl == null) { usageException('The `--git-url` is required for git dependencies.'); } + if (argResults.gitRef != null && argResults.tagPattern != null) { + usageException( + 'Cannot provide both `--git-ref` and `--git-tag-pattern`.', + ); + } /// Process the git options to return the simplest representation to be /// added to the pubspec. @@ -554,6 +564,7 @@ Specify multiple sdk packages with descriptors.'''); containingDir: p.current, ref: argResults.gitRef, path: argResults.gitPath, + tagPattern: argResults.tagPattern, ), ); } on FormatException catch (e) { @@ -789,6 +800,8 @@ extension on ArgResults { bool get isDryRun => flag('dry-run'); String? get gitUrl => this['git-url'] as String?; String? get gitPath => this['git-path'] as String?; + String? get tagPattern => this['git-tag-pattern'] as String?; + String? get gitRef => this['git-ref'] as String?; String? get hostedUrl => this['hosted-url'] as String?; String? get path => this['path'] as String?; diff --git a/lib/src/command/dependency_services.dart b/lib/src/command/dependency_services.dart index 738feddda..9b8c63acb 100644 --- a/lib/src/command/dependency_services.dart +++ b/lib/src/command/dependency_services.dart @@ -424,6 +424,7 @@ class DependencyServicesApplyCommand extends PubCommand { } else if (targetRevision != null && (lockFileYaml['packages'] as Map).containsKey(targetPackage)) { final ref = entrypoint.lockFile.packages[targetPackage]!.toRef(); + final currentDescription = ref.description as GitDescription; final updatedRef = PackageRef( targetPackage, @@ -432,6 +433,7 @@ class DependencyServicesApplyCommand extends PubCommand { path: currentDescription.path, ref: targetRevision, containingDir: directory, + tagPattern: currentDescription.tagPattern, ), ); final versions = await cache.getVersions(updatedRef); diff --git a/lib/src/global_packages.dart b/lib/src/global_packages.dart index 5a3095330..93324478b 100644 --- a/lib/src/global_packages.dart +++ b/lib/src/global_packages.dart @@ -93,6 +93,7 @@ class GlobalPackages { required bool overwriteBinStubs, String? path, String? ref, + String? tagPattern, }) async { final name = await cache.git.getPackageNameFromRepo( repo, @@ -100,6 +101,7 @@ class GlobalPackages { path, cache, relativeTo: p.current, + tagPattern: tagPattern, ); // TODO(nweiz): Add some special handling for git repos that contain path diff --git a/lib/src/language_version.dart b/lib/src/language_version.dart index 6bf4c8adf..24995dbc3 100644 --- a/lib/src/language_version.dart +++ b/lib/src/language_version.dart @@ -65,6 +65,8 @@ class LanguageVersion implements Comparable { bool get supportsWorkspaces => this >= firstVersionWithWorkspaces; + bool get supportsTagPattern => this >= firstVersionWithTagPattern; + bool get forbidsUnknownDescriptionKeys => this >= firstVersionForbidingUnknownDescriptionKeys; @@ -105,6 +107,7 @@ class LanguageVersion implements Comparable { static const firstVersionWithNullSafety = LanguageVersion(2, 12); static const firstVersionWithShorterHostedSyntax = LanguageVersion(2, 15); static const firstVersionWithWorkspaces = LanguageVersion(3, 5); + static const firstVersionWithTagPattern = LanguageVersion(3, 7); static const firstVersionForbidingUnknownDescriptionKeys = LanguageVersion( 3, 7, diff --git a/lib/src/source/git.dart b/lib/src/source/git.dart index 8c7ff0470..1f16e7e6c 100644 --- a/lib/src/source/git.dart +++ b/lib/src/source/git.dart @@ -24,6 +24,8 @@ import 'cached.dart'; import 'path.dart'; import 'root.dart'; +typedef TaggedVersion = ({Version version, String commitId}); + /// A package source that gets packages from Git repos. class GitSource extends CachedSource { static GitSource instance = GitSource._(); @@ -43,6 +45,7 @@ class GitSource extends CachedSource { String url; String? ref; String? path; + String? tagPattern; if (description is String) { url = description; } else if (description is! Map) { @@ -77,9 +80,35 @@ class GitSource extends CachedSource { } path = descriptionPath; + // TODO: can we avoid relying on key presence? + if (description.containsKey('tag_pattern')) { + if (!languageVersion.supportsTagPattern) { + throw FormatException( + 'Using `git: {tagPattern: }` is only supported with a minimum SDK ' + 'constraint of ${LanguageVersion.firstVersionWithTagPattern}.', + ); + } + switch (description['tag_pattern']) { + case final String descriptionTagPattern: + tagPattern = descriptionTagPattern; + // Do an early compilation to validate the format. + compileTagPattern(tagPattern); + default: + throw const FormatException( + "The 'tag_pattern' field of the description " + 'must be a string or null.', + ); + } + } + + if (ref != null && tagPattern != null) { + throw const FormatException( + 'A git description cannot have both a ref and a `tag_pattern`.', + ); + } if (languageVersion.forbidsUnknownDescriptionKeys) { for (final key in description.keys) { - if (!['url', 'ref', 'path'].contains(key)) { + if (!['url', 'ref', 'path', 'tag_pattern'].contains(key)) { throw FormatException('Unknown key "$key" in description.'); } } @@ -99,6 +128,7 @@ class GitSource extends CachedSource { containingDir: containingDir, ref: ref, path: _validatedPath(path), + tagPattern: tagPattern, ), ); } @@ -140,6 +170,15 @@ class GitSource extends CachedSource { 'must be a string.', ); } + + final tagPattern = description['tag_pattern']; + if (tagPattern is! String?) { + throw const FormatException( + "The 'tag_pattern' field of the description " + 'must be a string.', + ); + } + return PackageId( name, version, @@ -149,6 +188,7 @@ class GitSource extends CachedSource { ref: ref, path: _validatedPath(description['path']), containingDir: containingDir, + tagPattern: tagPattern, ), resolvedRef, ), @@ -251,17 +291,29 @@ class GitSource extends CachedSource { String? path, SystemCache cache, { required String relativeTo, + required String? tagPattern, }) async { + if (ref != null && tagPattern != null) { + fail('Cannot have both a `tagPattern` and a `ref`'); + } final description = GitDescription( url: url, ref: ref, path: path, containingDir: relativeTo, + tagPattern: tagPattern, ); return await _pool.withResource(() async { await _ensureRepoCache(description, cache); final path = _repoCachePath(description, cache); - final revision = await _firstRevision(path, description.ref); + + final revision = + tagPattern != null + ? (await _listTaggedVersions( + path, + compileTagPattern(tagPattern), + )).last.commitId + : await _firstRevision(path, description.ref); final resolvedDescription = ResolvedGitDescription(description, revision); return Pubspec.parse( @@ -322,16 +374,42 @@ class GitSource extends CachedSource { return await _pool.withResource(() async { await _ensureRepoCache(description, cache); final path = _repoCachePath(description, cache); - final revision = await _firstRevision(path, description.ref); - final pubspec = await _describeUncached(ref, revision, cache); - - return [ - PackageId( - ref.name, - pubspec.version, - ResolvedGitDescription(description, revision), - ), - ]; + final result = []; + if (description.tagPattern case final String tagPattern) { + final versions = await _listTaggedVersions( + path, + compileTagPattern(tagPattern), + ); + for (final version in versions) { + result.add( + PackageId( + ref.name, + version.version, + ResolvedGitDescription(description, version.commitId), + ), + ); + } + return result; + } else { + final revision = await _firstRevision(path, description.ref); + + final Pubspec pubspec; + pubspec = await _describeUncached(ref, revision, cache); + result.add( + PackageId( + ref.name, + pubspec.version, + ResolvedGitDescription(description, revision), + ), + ); + return [ + PackageId( + ref.name, + pubspec.version, + ResolvedGitDescription(description, revision), + ), + ]; + } }); } @@ -688,6 +766,35 @@ class GitSource extends CachedSource { String _packageListPath(String revisionCachePath) => p.join(revisionCachePath, '.git/pub-packages'); + /// List all tags in [path] and returns all versions matching + /// [compiledTagPattern]. + Future> _listTaggedVersions( + String path, + RegExp compiledTagPattern, + ) async { + final output = await git.run([ + 'tag', + '--list', + '--format', + // We can use space here, as it is not allowed in a git tag + // https://git-scm.com/docs/git-check-ref-format + '%(refname:lstrip=2) %(objectname)', + ], workingDir: path); + final lines = output.trim().split('\n'); + final result = []; + for (final line in lines) { + final parts = line.split(' '); + if (parts.length != 2) { + throw PackageNotFoundException('Bad output from `git tag --list`'); + } + final match = compiledTagPattern.firstMatch(parts[0]); + if (match == null) continue; + final version = Version.parse(match[1]!); + result.add((version: version, commitId: parts[1])); + } + return result; + } + /// Runs "git rev-list" on [reference] in [path] and returns the first result. /// /// This assumes that the canonical clone already exists. @@ -801,6 +908,8 @@ class GitDescription extends Description { /// not allow strings of the form: 'git@github.com:dart-lang/pub.git'. final String url; + final String? tagPattern; + /// `true` if [url] was parsed from a relative url. final bool relative; @@ -812,11 +921,14 @@ class GitDescription extends Description { /// Represented as a relative url. final String path; + late final RegExp compiledTagPattern = compileTagPattern(tagPattern!); + GitDescription.raw({ required this.url, required this.relative, required String? ref, required String? path, + required this.tagPattern, }) : ref = ref ?? 'HEAD', path = path ?? '.'; @@ -825,6 +937,7 @@ class GitDescription extends Description { required String? ref, required String? path, required String? containingDir, + required String? tagPattern, }) { final validatedUrl = GitSource._validatedUrl(url, containingDir); return GitDescription.raw( @@ -832,6 +945,7 @@ class GitDescription extends Description { relative: validatedUrl.wasRelative, ref: ref, path: path, + tagPattern: tagPattern, ); } @@ -856,11 +970,12 @@ class GitDescription extends Description { from: p.toUri(p.normalize(p.absolute(containingDir))).toString(), ) : url; - if (ref == 'HEAD' && path == '.') return relativeUrl; + if (ref == 'HEAD' && path == '.' && tagPattern == null) return relativeUrl; return { 'url': relativeUrl, if (ref != 'HEAD') 'ref': ref, if (path != '.') 'path': path, + if (tagPattern != null) 'tag_pattern': tagPattern, }; } @@ -875,8 +990,13 @@ class GitDescription extends Description { other.path == path; } - GitDescription withRef(String newRef) => - GitDescription.raw(url: url, relative: relative, ref: newRef, path: path); + GitDescription withRef(String newRef) => GitDescription.raw( + url: url, + relative: relative, + ref: newRef, + path: path, + tagPattern: tagPattern, + ); @override int get hashCode => Object.hash(url, ref, path); @@ -894,7 +1014,7 @@ class GitDescription extends Description { } @override - bool get hasMultipleVersions => false; + bool get hasMultipleVersions => tagPattern != null; } class ResolvedGitDescription extends ResolvedDescription { @@ -925,7 +1045,11 @@ class ResolvedGitDescription extends ResolvedDescription { : description.url; return { 'url': url, - 'ref': description.ref, + + if (description.tagPattern == null) + 'ref': description.ref + else + 'tag-pattern': description.tagPattern, 'resolved-ref': resolvedRef, 'path': description.path, }; @@ -954,3 +1078,33 @@ String _gitDirArg(String path) { Platform.isWindows ? path.replaceAll('\\', '/') : path; return '--git-dir=$forwardSlashPath'; } + +final tagPatternPattern = RegExp(r'^(.*){{version}}(.*)$'); + +// Adapted from pub_semver-2.1.4/lib/src/version.dart +const versionPattern = + r'(\d+)\.(\d+)\.(\d+)' // Version number. + r'(-([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?' // Pre-release. + r'(\+([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?'; // build + +/// Takes a [tagPattern] and returns a [RegExp] matching the relevant tags. +/// +/// The tagPattern should contain '{{version}}' which will match a pub_semver +/// version. The rest of the tagPattern is matched verbatim. +RegExp compileTagPattern(String tagPattern) { + final match = tagPatternPattern.firstMatch(tagPattern); + if (match == null) { + throw const FormatException( + 'The `tag_pattern` must contain "{{version}}" ' + 'to match different versions', + ); + } + final before = RegExp.escape(match[1]!); + final after = RegExp.escape(match[2]!); + + return RegExp( + r'^' + '$before($versionPattern)$after' + r'$', + ); +} diff --git a/lib/src/source/hosted.dart b/lib/src/source/hosted.dart index 9b73cb327..54b98381b 100644 --- a/lib/src/source/hosted.dart +++ b/lib/src/source/hosted.dart @@ -454,7 +454,6 @@ class HostedSource extends CachedSource { } advisoriesDate = DateTime.parse(advisoriesUpdated); } - final status = PackageStatus( isDiscontinued: isDiscontinued, discontinuedReplacedBy: replacedBy, @@ -546,16 +545,20 @@ class HostedSource extends CachedSource { if (listing == null || listing.isEmpty) return; final latestVersion = maxBy<_VersionInfo, Version>(listing, (e) => e.version)!; - final dependencies = latestVersion.pubspec.dependencies.values; - unawaited( - withDependencyType(DependencyType.none, () async { - for (final packageRange in dependencies) { - if (packageRange.source is HostedSource) { - preschedule!(_RefAndCache(packageRange.toRef(), cache)); + try { + final dependencies = latestVersion.pubspec.dependencies.values; + unawaited( + withDependencyType(DependencyType.none, () async { + for (final packageRange in dependencies) { + if (packageRange.source is HostedSource) { + preschedule!(_RefAndCache(packageRange.toRef(), cache)); + } } - } - }), - ); + }), + ); + } on FormatException { + // Ignore malformed dependencies. + } } final cache = refAndCache.cache; diff --git a/lib/src/source/path.dart b/lib/src/source/path.dart index 7859e5e99..a5939e771 100644 --- a/lib/src/source/path.dart +++ b/lib/src/source/path.dart @@ -115,6 +115,7 @@ class PathSource extends Source { relative: containingDescription.description.relative, // Always refer to the same commit as the containing pubspec. ref: containingDescription.resolvedRef, + tagPattern: null, path: resolvedPath, ), ); diff --git a/test/add/git/git_test.dart b/test/add/git/git_test.dart index 90d8f3916..5155ee363 100644 --- a/test/add/git/git_test.dart +++ b/test/add/git/git_test.dart @@ -280,4 +280,51 @@ void main() { }), ]).validate(); }); + + test('Can add git tag_pattern using descriptors', () async { + ensureGit(); + + await d.git('foo.git', [ + d.libDir('foo'), + d.libPubspec('foo', '1.0.0'), + ]).create(); + + await d.git('foo.git').tag('v1.0.0'); + + await d.git('foo.git', [ + d.libDir('foo'), + d.libPubspec('foo', '2.0.0'), + ]).commit(); + await d.git('foo.git').tag('v2.0.0'); + await d.git('foo.git', [ + d.libDir('foo'), + d.libPubspec('foo', '3.0.0'), + ]).commit(); // Not tagged, we won't get this. + await d + .appDir( + dependencies: {}, + pubspec: { + 'environment': {'sdk': '^3.9.0'}, + }, + ) + .create(); + + await pubAdd( + args: ['foo:{"git":{"url": "../foo.git", "tag_pattern":"v{{version}}"}}'], + environment: {'_PUB_TEST_SDK_VERSION': '3.9.0'}, + ); + + await d.dir(appPath, [ + d.pubspec({ + 'name': 'myapp', + 'environment': {'sdk': '^3.9.0'}, + 'dependencies': { + 'foo': { + 'git': {'url': '../foo.git', 'tag_pattern': 'v{{version}}'}, + 'version': '^2.0.0', + }, + }, + }), + ]).validate(); + }); } diff --git a/test/descriptor/git.dart b/test/descriptor/git.dart index c52b7dabb..15a43ac7a 100644 --- a/test/descriptor/git.dart +++ b/test/descriptor/git.dart @@ -36,6 +36,15 @@ class GitRepoDescriptor extends DirectoryDescriptor { ]); } + /// Adds a tag named [tag] to the repo described by `this`. + /// + /// [parent] defaults to [sandbox]. + Future tag(String tag, [String? parent]) async { + await _runGitCommands(parent, [ + ['tag', '-a', tag, '-m', 'Some message'], + ]); + } + /// Return a Future that completes to the commit in the git repository /// referred to by [ref]. /// diff --git a/test/get/git/check_out_and_upgrade_test.dart b/test/get/git/check_out_and_upgrade_test.dart index 2bf074327..bbe598a65 100644 --- a/test/get/git/check_out_and_upgrade_test.dart +++ b/test/get/git/check_out_and_upgrade_test.dart @@ -61,4 +61,77 @@ void main() { expect(packageSpec('foo'), isNot(originalFooSpec)); }); + + test('checks out and upgrades a package from with a tag-pattern', () async { + ensureGit(); + + final repo = d.git('foo.git', [ + d.libDir('foo'), + d.libPubspec('foo', '1.0.0'), + ]); + await repo.create(); + await repo.tag('v1.0.0'); + + await d + .appDir( + dependencies: { + 'foo': { + 'git': {'url': '../foo.git', 'tag_pattern': 'v{{version}}'}, + 'version': '^1.0.0', + }, + }, + pubspec: { + 'environment': {'sdk': '^3.9.0'}, + }, + ) + .create(); + + await pubGet( + output: contains('+ foo 1.0.0'), + environment: {'_PUB_TEST_SDK_VERSION': '3.9.0'}, + ); + + // This should be found by `pub upgrade`. + await d.git('foo.git', [ + d.libDir('foo'), + d.libPubspec('foo', '1.5.0'), + ]).commit(); + await repo.tag('v1.5.0'); + + // The untagged version should not be found by `pub upgrade`. + await d.git('foo.git', [ + d.libDir('foo'), + d.libPubspec('foo', '1.7.0'), + ]).commit(); + + // This should be found by `pub upgrade --major-versions` + await d.git('foo.git', [ + d.libDir('foo'), + d.libPubspec('foo', '2.0.0'), + ]).commit(); + await repo.tag('v2.0.0'); + + // A version that is not tagged according to the pattern should not be + // chosen by the `upgrade --major-versions`. + await d.git('foo.git', [ + d.libDir('foo'), + d.libPubspec('foo', '3.0.0'), + ]).commit(); + await repo.tag('unrelatedTag'); + + await pubUpgrade( + output: allOf(contains('> foo 1.5.0'), contains('Changed 1 dependency!')), + environment: {'_PUB_TEST_SDK_VERSION': '3.9.0'}, + ); + + await pubUpgrade( + args: ['--major-versions'], + output: allOf( + contains('> foo 2.0.0'), + contains('foo: ^1.0.0 -> ^2.0.0'), + contains('Changed 1 dependency!'), + ), + environment: {'_PUB_TEST_SDK_VERSION': '3.9.0'}, + ); + }); } diff --git a/test/get/git/ssh_url_test.dart b/test/get/git/ssh_url_test.dart index 81bf45e69..43c13f612 100644 --- a/test/get/git/ssh_url_test.dart +++ b/test/get/git/ssh_url_test.dart @@ -23,6 +23,11 @@ void main() { ref: 'main', path: 'abc/', containingDir: null, + tagPattern: null, + ); + expect( + description.format(), + 'git@github.com:dart-lang/pub.git at main in abc/', ); expect( description.format(), diff --git a/test/get/git/tag_pattern_test.dart b/test/get/git/tag_pattern_test.dart new file mode 100644 index 000000000..afe97641b --- /dev/null +++ b/test/get/git/tag_pattern_test.dart @@ -0,0 +1,288 @@ +// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. 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:path/path.dart' as p; +import 'package:pub/src/exit_codes.dart'; +import 'package:test/test.dart'; +import 'package:yaml/yaml.dart'; + +import '../../descriptor.dart' as d; +import '../../test_pub.dart'; + +void main() { + test('Versions inside a tag_pattern dependency can depend on versions from ' + 'another commit', () async { + ensureGit(); + await d.git('foo.git', [ + d.libPubspec( + 'foo', + '1.0.0', + sdk: '^3.9.0', + deps: { + 'bar': { + 'git': { + 'url': p.join(d.sandbox, 'bar'), + 'tag_pattern': '{{version}}', + }, + 'version': '^2.0.0', + }, + }, + ), + ]).create(); + await d.git('foo.git', []).tag('1.0.0'); + + await d.git('foo.git', [ + d.libPubspec( + 'foo', + '2.0.0', + sdk: '^3.9.0', + deps: { + 'bar': { + 'git': { + 'url': p.join(d.sandbox, 'bar.git'), + 'tag_pattern': '{{version}}', + }, + 'version': '^1.0.0', + }, + }, + ), + ]).commit(); + await d.git('foo.git', []).tag('2.0.0'); + + await d.git('bar.git', [ + d.libPubspec( + 'bar', + '1.0.0', + sdk: '^3.9.0', + deps: { + 'foo': { + 'git': { + 'url': p.join(d.sandbox, 'bar.git'), + 'tag_pattern': '{{version}}', + }, + 'version': '^2.0.0', + }, + }, + ), + ]).create(); + await d.git('bar.git', []).tag('1.0.0'); + + await d.git('bar.git', [ + d.libPubspec( + 'bar', + '2.0.0', + sdk: '^3.9.0', + deps: { + 'foo': { + 'git': { + 'url': p.join(d.sandbox, 'foo.git'), + 'tag_pattern': '{{version}}', + }, + 'version': '^1.0.0', + }, + }, + ), + ]).commit(); + await d.git('bar.git', []).tag('2.0.0'); + + await d + .appDir( + dependencies: { + 'foo': { + 'git': { + 'url': p.join(d.sandbox, 'foo.git'), + 'tag_pattern': '{{version}}', + }, + 'version': '^1.0.0', + }, + }, + pubspec: { + 'environment': {'sdk': '^3.9.0'}, + }, + ) + .create(); + + await pubGet( + output: allOf(contains('+ foo 1.0.0'), contains('+ bar 2.0.0')), + environment: {'_PUB_TEST_SDK_VERSION': '3.9.0'}, + ); + final pubspec = loadYaml( + File(p.join(d.sandbox, appPath, 'pubspec.lock')).readAsStringSync(), + ); + final s = Platform.pathSeparator; + final foo = ((pubspec as Map)['packages'] as Map)['foo']; + expect(foo, { + 'dependency': 'direct main', + 'description': { + 'path': '.', + 'resolved-ref': isA(), + 'tag-pattern': '{{version}}', + 'url': '${d.sandbox}${s}foo.git', + }, + 'source': 'git', + 'version': '1.0.0', + }); + }); + + test('Versions inside a tag_pattern dependency cannot depend on ' + 'version from another commit via path-dependencies', () async { + ensureGit(); + + await d.git('repo.git', [ + d.dir('foo', [ + d.libPubspec( + 'foo', + '1.0.0', + deps: { + 'bar': {'path': '../bar', 'version': '^2.0.0'}, + }, + ), + ]), + d.dir('bar', [ + d.libPubspec( + 'bar', + '2.0.0', + deps: { + 'foo': {'path': '../foo', 'version': '^1.0.0'}, + }, + ), + ]), + ]).create(); + await d.git('repo.git', []).tag('foo-1.0.0'); + await d.git('repo.git', []).tag('bar-2.0.0'); + + await d.git('repo.git', [ + d.dir('foo', [ + d.libPubspec( + 'foo', + '2.0.0', + deps: { + 'bar': {'path': '../bar', 'version': '^2.0.0'}, + }, + ), + ]), + d.dir('bar', [ + d.libPubspec( + 'bar', + '1.0.0', + deps: { + 'foo': {'path': '../foo', 'version': '^1.0.0'}, + }, + ), + ]), + ]).commit(); + await d.git('repo.git', []).tag('foo-2.0.0'); + await d.git('repo.git', []).tag('bar-1.0.0'); + + await d + .appDir( + dependencies: { + 'foo': { + 'git': { + 'url': '../repo.git', + 'tag_pattern': 'foo-{{version}}', + 'path': 'foo', + }, + 'version': '^1.0.0', + }, + }, + pubspec: { + 'environment': {'sdk': '^3.9.0'}, + }, + ) + .create(); + final s = RegExp.escape(p.separator); + await pubGet( + error: matches( + 'Because foo from git ..${s}repo.git at HEAD in foo ' + 'depends on bar \\^2.0.0 from git ' + 'which depends on foo from git ..${s}repo.git at [a-f0-9]+ in foo, ' + 'foo <2.0.0 from git is forbidden', + ), + environment: {'_PUB_TEST_SDK_VERSION': '3.9.0'}, + ); + }); + + test('tag_pattern must contain "{{version}}"', () async { + await d + .appDir( + dependencies: { + 'foo': { + 'git': {'url': 'some/git/path', 'tag_pattern': 'v100'}, + }, + }, + pubspec: { + 'environment': {'sdk': '^3.9.0'}, + }, + ) + .create(); + + await pubGet( + environment: {'_PUB_TEST_SDK_VERSION': '3.9.0'}, + error: contains( + 'Invalid description in the "myapp" pubspec on the "foo" dependency: ' + 'The `tag_pattern` must contain "{{version}}" ' + 'to match different versions', + ), + exitCode: DATA, + ); + }); + + test( + 'tagged version must contain the correct version of dependency', + () async { + await d.git('foo.git', [d.libPubspec('foo', '1.0.0')]).create(); + await d.git('foo.git', []).tag('v1.0.0'); + await d.git('foo.git', [d.libPubspec('foo', '2.0.0')]).commit(); + await d.git('foo.git', []).tag('v3.0.0'); // Wrong tag, will not be found. + + await d + .appDir( + dependencies: { + 'foo': { + 'git': {'url': '../foo', 'tag_pattern': 'v{{version}}'}, + }, + }, + pubspec: { + 'environment': {'sdk': '^3.9.0'}, + }, + ) + .create(); + + await pubGet( + environment: {'_PUB_TEST_SDK_VERSION': '3.9.0'}, + output: contains('+ foo 1.0.0'), + ); + }, + ); + + test('Reasonable error when no tagged versions exist', () async { + await d.git('foo.git', [d.libPubspec('foo', '1.0.0')]).create(); + await d.git('foo.git', [d.libPubspec('foo', '2.0.0')]).commit(); + + await d + .appDir( + dependencies: { + 'foo': { + 'git': {'url': '../foo', 'tag_pattern': 'v{{version}}'}, + }, + }, + pubspec: { + 'environment': {'sdk': '^3.9.0'}, + }, + ) + .create(); + + await pubGet( + environment: {'_PUB_TEST_SDK_VERSION': '3.9.0'}, + exitCode: UNAVAILABLE, + error: contains( + 'Because myapp depends on foo any from git which doesn\'t exist ' + '(Bad output from `git tag --list`)', + ), + ); + }); +}