Add 'tag_pattern' feature to git dependencies (#4427)

diff --git a/lib/src/command/add.dart b/lib/src/command/add.dart
index f5f8d22..7c6c701 100644
--- a/lib/src/command/add.dart
+++ b/lib/src/command/add.dart
@@ -118,6 +118,11 @@
       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',
       hide: true,
@@ -543,6 +548,11 @@
       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 @@
             containingDir: p.current,
             ref: argResults.gitRef,
             path: argResults.gitPath,
+            tagPattern: argResults.tagPattern,
           ),
         );
       } on FormatException catch (e) {
@@ -789,6 +800,8 @@
   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 738fedd..9b8c63a 100644
--- a/lib/src/command/dependency_services.dart
+++ b/lib/src/command/dependency_services.dart
@@ -424,6 +424,7 @@
         } 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 @@
               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 5a30953..9332447 100644
--- a/lib/src/global_packages.dart
+++ b/lib/src/global_packages.dart
@@ -93,6 +93,7 @@
     required bool overwriteBinStubs,
     String? path,
     String? ref,
+    String? tagPattern,
   }) async {
     final name = await cache.git.getPackageNameFromRepo(
       repo,
@@ -100,6 +101,7 @@
       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 6bf4c8a..fdfb2bf 100644
--- a/lib/src/language_version.dart
+++ b/lib/src/language_version.dart
@@ -65,6 +65,8 @@
 
   bool get supportsWorkspaces => this >= firstVersionWithWorkspaces;
 
+  bool get supportsTagPattern => this >= firstVersionWithTagPattern;
+
   bool get forbidsUnknownDescriptionKeys =>
       this >= firstVersionForbidingUnknownDescriptionKeys;
 
@@ -105,6 +107,7 @@
   static const firstVersionWithNullSafety = LanguageVersion(2, 12);
   static const firstVersionWithShorterHostedSyntax = LanguageVersion(2, 15);
   static const firstVersionWithWorkspaces = LanguageVersion(3, 5);
+  static const firstVersionWithTagPattern = LanguageVersion(3, 9);
   static const firstVersionForbidingUnknownDescriptionKeys = LanguageVersion(
     3,
     7,
diff --git a/lib/src/source/git.dart b/lib/src/source/git.dart
index 8c7ff04..91d55af 100644
--- a/lib/src/source/git.dart
+++ b/lib/src/source/git.dart
@@ -24,6 +24,8 @@
 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 @@
     String url;
     String? ref;
     String? path;
+    String? tagPattern;
     if (description is String) {
       url = description;
     } else if (description is! Map) {
@@ -77,9 +80,35 @@
       }
       path = descriptionPath;
 
+      final descriptionTagPattern = description['tag_pattern'];
+
+      if (descriptionTagPattern is! String?) {
+        throw const FormatException(
+          "The 'tag_pattern' field of the description "
+          'must be a string or null.',
+        );
+      } else {
+        if (descriptionTagPattern != null) {
+          if (!languageVersion.supportsTagPattern) {
+            throw FormatException(
+              'Using `git: {tagPattern: }` '
+              'is only supported with a minimum SDK '
+              'constraint of ${LanguageVersion.firstVersionWithTagPattern}.',
+            );
+          }
+          validateTagPattern(descriptionTagPattern);
+        }
+        tagPattern = descriptionTagPattern;
+      }
+
+      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 @@
         containingDir: containingDir,
         ref: ref,
         path: _validatedPath(path),
+        tagPattern: tagPattern,
       ),
     );
   }
@@ -140,6 +170,15 @@
         '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 @@
           ref: ref,
           path: _validatedPath(description['path']),
           containingDir: containingDir,
+          tagPattern: tagPattern,
         ),
         resolvedRef,
       ),
@@ -251,17 +291,27 @@
     String? path,
     SystemCache cache, {
     required String relativeTo,
+    required String? tagPattern,
   }) async {
+    assert(
+      !(ref != null && tagPattern != null),
+      '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, tagPattern)).last.commitId
+              : await _firstRevision(path, description.ref);
       final resolvedDescription = ResolvedGitDescription(description, revision);
 
       return Pubspec.parse(
@@ -322,16 +372,39 @@
     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);
+      final result = <PackageId>[];
+      if (description.tagPattern case final String tagPattern) {
+        final versions = await _listTaggedVersions(path, 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);
 
-      return [
-        PackageId(
-          ref.name,
-          pubspec.version,
-          ResolvedGitDescription(description, revision),
-        ),
-      ];
+        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 +761,45 @@
   String _packageListPath(String revisionCachePath) =>
       p.join(revisionCachePath, '.git/pub-packages');
 
+  /// List all tags in [path] and returns all versions matching
+  /// [tagPattern].
+  Future<List<TaggedVersion>> _listTaggedVersions(
+    String path,
+    String tagPattern,
+  ) 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 = <TaggedVersion>[];
+    final compiledTagPattern = compileTagPattern(tagPattern);
+    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;
+
+      try {
+        version = Version.parse(match[1]!);
+      } on FormatException catch (e) {
+        throw StateError(
+          'Matched part ${Version.parse(match[1]!)} did not match version $e.',
+        );
+      }
+      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 +913,12 @@
   /// not allow strings of the form: 'git@github.com:dart-lang/pub.git'.
   final String url;
 
+  /// A string containing [tagPatternVersionMarker] used to match version
+  /// numbers in a git tag. For example "v{{version}}".
+  ///
+  /// Only one of [ref] and [tagPattern] can be non-`null` at a time.
+  final String? tagPattern;
+
   /// `true` if [url] was parsed from a relative url.
   final bool relative;
 
@@ -812,11 +930,14 @@
   /// 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 +946,7 @@
     required String? ref,
     required String? path,
     required String? containingDir,
+    required String? tagPattern,
   }) {
     final validatedUrl = GitSource._validatedUrl(url, containingDir);
     return GitDescription.raw(
@@ -832,6 +954,7 @@
       relative: validatedUrl.wasRelative,
       ref: ref,
       path: path,
+      tagPattern: tagPattern,
     );
   }
 
@@ -856,11 +979,12 @@
               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 +999,13 @@
         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 +1023,7 @@
   }
 
   @override
-  bool get hasMultipleVersions => false;
+  bool get hasMultipleVersions => tagPattern != null;
 }
 
 class ResolvedGitDescription extends ResolvedDescription {
@@ -925,7 +1054,11 @@
             : 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 +1087,42 @@
       Platform.isWindows ? path.replaceAll('\\', '/') : path;
   return '--git-dir=$forwardSlashPath';
 }
+
+const String tagPatternVersionMarker = '{{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
+
+/// Throws [FormatException] if it doesn't contain a single instance of
+/// [tagPatternVersionMarker].
+void validateTagPattern(String tagPattern) {
+  final parts = tagPattern.split(tagPatternVersionMarker);
+  if (parts.length != 2) {
+    throw const FormatException(
+      'The `tag_pattern` must contain a single "{{version}}" '
+      'to match different versions',
+    );
+  }
+}
+
+/// 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.
+///
+/// Assumes that [tagPattern] has a single occurence of
+/// [tagPatternVersionMarker].
+RegExp compileTagPattern(String tagPattern) {
+  final parts = tagPattern.split(tagPatternVersionMarker);
+  final before = parts[0];
+  final after = parts[1];
+
+  return RegExp(
+    r'^'
+    '$before($versionPattern)$after'
+    r'$',
+  );
+}
diff --git a/lib/src/source/hosted.dart b/lib/src/source/hosted.dart
index 9b73cb3..f28ce01 100644
--- a/lib/src/source/hosted.dart
+++ b/lib/src/source/hosted.dart
@@ -454,7 +454,6 @@
         }
         advisoriesDate = DateTime.parse(advisoriesUpdated);
       }
-
       final status = PackageStatus(
         isDiscontinued: isDiscontinued,
         discontinuedReplacedBy: replacedBy,
diff --git a/lib/src/source/path.dart b/lib/src/source/path.dart
index 7859e5e..a5939e7 100644
--- a/lib/src/source/path.dart
+++ b/lib/src/source/path.dart
@@ -115,6 +115,7 @@
           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 90d8f39..5155ee3 100644
--- a/test/add/git/git_test.dart
+++ b/test/add/git/git_test.dart
@@ -280,4 +280,51 @@
       }),
     ]).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 2261486..0ceb72f 100644
--- a/test/descriptor/git.dart
+++ b/test/descriptor/git.dart
@@ -36,6 +36,15 @@
     ]);
   }
 
+  /// 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 2bf0743..bbe598a 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 @@
 
     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 81bf45e..43c13f6 100644
--- a/test/get/git/ssh_url_test.dart
+++ b/test/get/git/ssh_url_test.dart
@@ -23,6 +23,11 @@
         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 0000000..0f36ae4
--- /dev/null
+++ b/test/get/git/tag_pattern_test.dart
@@ -0,0 +1,316 @@
+// 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<String>(),
+        '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 a single "{{version}}" '
+        'to match different versions',
+      ),
+      exitCode: DATA,
+    );
+  });
+
+  test('tag_pattern must contain at most one "{{version}}"', () async {
+    await d
+        .appDir(
+          dependencies: {
+            'foo': {
+              'git': {
+                'url': 'some/git/path',
+                'tag_pattern': 'v{{version}}{{version}}',
+              },
+            },
+          },
+          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 a single "{{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`)',
+      ),
+    );
+  });
+}