Adding the `pub add` command (#2618)

* Naive implementation of pub add

* Minor touching up

* Resolves packages in memory before getting them

* Moved functions to addCommand

* Warns if dependency is present in dev-dependency or otherwise

* Allow package:any

* Naive implementation of PubspecDescriptor

* Added tests

* Extended add functionality

* Added remove functionality

* Updated add functionality

* Updated tests for add command

* Added tests for remove

* Add more tests

* Added hosted dependencies

* Updated hosted package option

* Added tests

* Throws DataError if result of version resolution was unexpected by user

* Fixes tests

* Updated code according to review comments

* Fixed bug in add to handle the dependencies map not existing.

* Sdk packages support

* Update dependencies

* Updated comments

* Fixed bug in pubspec validation

* Fixed bug in pubspec validation

* Fixed as per review comments

* Removed remove

* Removed more remove stuff

* More minor edits

* Fixes to tests as per commands

* Allowed version constraints in path/git dependencies

* Fixed more tests, improved behavior re: transitive deps

* Updated test for windows

* More windows fixes

* Fixed as per review comments

* Fixed tests

* Fixed nits

* Waiting for yaml_edit:1.0.1

* Updated yaml_edit version
diff --git a/lib/src/command/add.dart b/lib/src/command/add.dart
new file mode 100644
index 0000000..4839545
--- /dev/null
+++ b/lib/src/command/add.dart
@@ -0,0 +1,404 @@
+// Copyright (c) 2020, 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 'package:pub_semver/pub_semver.dart';
+import 'package:yaml_edit/yaml_edit.dart';
+
+import '../command.dart';
+import '../entrypoint.dart';
+import '../exceptions.dart';
+import '../git.dart';
+import '../io.dart';
+import '../log.dart' as log;
+import '../package.dart';
+import '../package_name.dart';
+import '../pubspec.dart';
+import '../solver.dart';
+import '../utils.dart';
+
+/// Handles the `add` pub command. Adds a dependency to `pubspec.yaml` and gets
+/// the package. The user may pass in a git constraint, host url, or path as
+/// requirements. If no such options are passed in, this command will do a
+/// resolution to find the latest version of the package that is compatible with
+/// the other dependencies in `pubpsec.yaml`, and then enter that as the lower
+/// bound in a ^x.y.z constraint.
+///
+/// Currently supports only adding one dependency at a time.
+class AddCommand extends PubCommand {
+  @override
+  String get name => 'add';
+  @override
+  String get description => 'Add a dependency to pubspec.yaml.';
+  @override
+  String get invocation => 'pub add <package>[:<constraint>] [options]';
+  @override
+  String get docUrl => 'https://dart.dev/tools/pub/cmd/pub-add';
+
+  bool get isDev => argResults['dev'];
+  bool get isDryRun => argResults['dry-run'];
+  String get gitUrl => argResults['git-url'];
+  String get gitPath => argResults['git-path'];
+  String get gitRef => argResults['git-ref'];
+  String get hostUrl => argResults['hosted-url'];
+  String get path => argResults['path'];
+  String get sdk => argResults['sdk'];
+
+  bool get hasGitOptions => gitUrl != null || gitRef != null || gitPath != null;
+  bool get hasHostOptions => hostUrl != null;
+
+  AddCommand() {
+    argParser.addFlag('dev',
+        abbr: 'd',
+        negatable: false,
+        help: 'Adds package to the development dependencies instead.');
+
+    argParser.addOption('git-url', help: 'Git URL of the package');
+    argParser.addOption('git-ref',
+        help: 'Git branch or commit to be retrieved');
+    argParser.addOption('git-path', help: 'Path of git package in repository');
+    argParser.addOption('hosted-url', help: 'URL of package host server');
+    argParser.addOption('path', help: 'Local path');
+    argParser.addOption('sdk', help: 'SDK source for package');
+
+    argParser.addFlag('offline',
+        help: 'Use cached packages instead of accessing the network.');
+
+    argParser.addFlag('dry-run',
+        abbr: 'n',
+        negatable: false,
+        help: "Report what dependencies would change but don't change any.");
+
+    argParser.addFlag('precompile',
+        help: 'Precompile executables in immediate dependencies.');
+  }
+
+  @override
+  Future run() async {
+    if (argResults.rest.isEmpty) {
+      usageException('Must specify a package to be added.');
+    }
+
+    final packageInformation = _parsePackage(argResults.rest.first);
+    final package = packageInformation.first;
+
+    /// Perform version resolution in-memory.
+    final updatedPubSpec =
+        await _addPackageToPubspec(entrypoint.root.pubspec, package);
+
+    SolveResult solveResult;
+
+    try {
+      /// Use [SolveType.UPGRADE] to solve for the highest version of [package]
+      /// in case [package] was already a transitive dependency. In the case
+      /// where the user specifies a version constraint, this serves to ensure
+      /// that a resolution exists before we update pubspec.yaml.
+      solveResult = await resolveVersions(
+          SolveType.UPGRADE, cache, Package.inMemory(updatedPubSpec));
+    } on GitException {
+      dataError('Unable to resolve package "${package.name}" with the given '
+          'git parameters.');
+    } on SolveFailure catch (e) {
+      dataError(e.message);
+    } on WrappedException catch (e) {
+      /// [WrappedException]s may appear if an invalid [hostUrl] is passed in.
+      dataError(e.message);
+    }
+
+    final resultPackage = solveResult.packages
+        .firstWhere((packageId) => packageId.name == package.name);
+
+    /// Assert that [resultPackage] is within the original user's expectations.
+    if (package.constraint != null &&
+        !package.constraint.allows(resultPackage.version)) {
+      if (updatedPubSpec.dependencyOverrides != null &&
+          updatedPubSpec.dependencyOverrides.isNotEmpty) {
+        dataError(
+            '"${package.name}" resolved to "${resultPackage.version}" which '
+            'does not satisfy constraint "${package.constraint}". This could be '
+            'caused by "dependency_overrides".');
+      }
+      dataError(
+          '"${package.name}" resolved to "${resultPackage.version}" which '
+          'does not satisfy constraint "${package.constraint}".');
+    }
+
+    if (isDryRun) {
+      /// Even if it is a dry run, run `acquireDependencies` so that the user
+      /// gets a report on the other packages that might change version due
+      /// to this new dependency.
+      final newRoot = Package.inMemory(updatedPubSpec);
+
+      await Entrypoint.global(newRoot, entrypoint.lockFile, cache,
+              solveResult: solveResult)
+          .acquireDependencies(SolveType.GET,
+              dryRun: true, precompile: argResults['precompile']);
+    } else {
+      /// Update the `pubspec.yaml` before calling [acquireDependencies] to
+      /// ensure that the modification timestamp on `pubspec.lock` and
+      /// `.dart_tool/package_config.json` is newer than `pubspec.yaml`,
+      /// ensuring that [entrypoint.assertUptoDate] will pass.
+      _updatePubspec(resultPackage, packageInformation, isDev);
+
+      /// Create a new [Entrypoint] since we have to reprocess the updated
+      /// pubspec file.
+      await Entrypoint.current(cache).acquireDependencies(SolveType.GET,
+          precompile: argResults['precompile']);
+    }
+
+    if (isOffline) {
+      log.warning('Warning: Packages added when offline may not resolve to '
+          'the latest compatible version available.');
+    }
+  }
+
+  /// Creates a new in-memory [Pubspec] by adding [package] to the
+  /// dependencies of [original].
+  Future<Pubspec> _addPackageToPubspec(
+      Pubspec original, PackageRange package) async {
+    ArgumentError.checkNotNull(original, 'original');
+    ArgumentError.checkNotNull(package, 'package');
+
+    final dependencies = [...original.dependencies.values];
+    var devDependencies = [...original.devDependencies.values];
+    final dependencyNames = dependencies.map((dependency) => dependency.name);
+    final devDependencyNames =
+        devDependencies.map((devDependency) => devDependency.name);
+
+    if (isDev) {
+      /// TODO(walnut): Change the error message once pub upgrade --bump is
+      /// released
+      if (devDependencyNames.contains(package.name)) {
+        usageException('"${package.name}" is already in "dev_dependencies". '
+            'Use "pub upgrade ${package.name}" to upgrade to a later version!');
+      }
+
+      /// If package is originally in dependencies and we wish to add it to
+      /// dev_dependencies, this is a redundant change, and we should not
+      /// remove the package from dependencies, since it might cause the user's
+      /// code to break.
+      if (dependencyNames.contains(package.name)) {
+        usageException('"${package.name}" is already in "dependencies". '
+            'Use "pub remove ${package.name}" to remove it before adding it '
+            'to "dev_dependencies"');
+      }
+
+      devDependencies.add(package);
+    } else {
+      /// TODO(walnut): Change the error message once pub upgrade --bump is
+      /// released
+      if (dependencyNames.contains(package.name)) {
+        usageException('"${package.name}" is already in "dependencies". '
+            'Use "pub upgrade ${package.name}" to upgrade to a later version!');
+      }
+
+      /// If package is originally in dev_dependencies and we wish to add it to
+      /// dependencies, we remove the package from dev_dependencies, since it is
+      /// now redundant.
+      if (devDependencyNames.contains(package.name)) {
+        log.message('"${package.name}" was found in dev_dependencies. '
+            'Removing "${package.name}" and adding it to dependencies instead.');
+        devDependencies =
+            devDependencies.where((d) => d.name != package.name).toList();
+      }
+
+      dependencies.add(package);
+    }
+
+    return Pubspec(
+      original.name,
+      version: original.version,
+      sdkConstraints: original.sdkConstraints,
+      dependencies: dependencies,
+      devDependencies: devDependencies,
+      dependencyOverrides: original.dependencyOverrides.values,
+    );
+  }
+
+  /// Parse [package] to return the corresponding [PackageRange], as well as its
+  /// representation in `pubspec.yaml`.
+  ///
+  /// [package] must be written in the format
+  /// `<package-name>[:<version-constraint>]`, where quotations should be used
+  /// if necessary.
+  ///
+  /// Examples:
+  /// ```
+  /// retry
+  /// retry:2.0.0
+  /// retry:^2.0.0
+  /// retry:'>=2.0.0'
+  /// retry:'>2.0.0 <3.0.1'
+  /// 'retry:>2.0.0 <3.0.1'
+  /// retry:any
+  /// ```
+  ///
+  /// If a version constraint is provided when the `--path` or any of the
+  /// `--git-<option>` options are used, a [PackageParseError] will be thrown.
+  ///
+  /// Packages must either be a git, hosted, sdk, or path package. Mixing of
+  /// options is not allowed and will cause a [PackageParseError] to be thrown.
+  ///
+  /// If any of the other git options are defined when `--git-url` is not
+  /// defined, an error will be thrown.
+  Pair<PackageRange, dynamic> _parsePackage(String package) {
+    ArgumentError.checkNotNull(package, 'package');
+
+    final _conflictingFlagSets = [
+      ['git-url', 'git-ref', 'git-path'],
+      ['hosted-url'],
+      ['path'],
+      ['sdk'],
+    ];
+
+    for (final flag
+        in _conflictingFlagSets.expand((s) => s).where(argResults.wasParsed)) {
+      final conflictingFlag = _conflictingFlagSets
+          .where((s) => !s.contains(flag))
+          .expand((s) => s)
+          .firstWhere(argResults.wasParsed, orElse: () => null);
+      if (conflictingFlag != null) {
+        usageException(
+            'Packages can only have one source, "pub add" flags "--$flag" and '
+            '"--$conflictingFlag" are conflicting.');
+      }
+    }
+
+    /// The package to be added, along with the user-defined package constraints
+    /// if present.
+    PackageRange packageRange;
+
+    /// The entry to be added to the pubspec. Assigned dynamic because it can
+    /// take on either a string for simple version constraints or a map for
+    /// more complicated hosted/git options.
+    dynamic pubspecInformation;
+
+    final splitPackage = package.split(':');
+    final packageName = splitPackage[0];
+
+    /// There shouldn't be more than one `:` in the package information
+    if (splitPackage.length > 2) {
+      usageException('Invalid package and version constraint: $package');
+    }
+
+    /// We want to allow for [constraint] to take on a `null` value here to
+    /// preserve the fact that the user did not specify a constraint.
+    VersionConstraint constraint;
+
+    try {
+      constraint = splitPackage.length == 2
+          ? VersionConstraint.parse(splitPackage[1])
+          : null;
+    } on FormatException catch (e) {
+      usageException('Invalid version constraint: ${e.message}');
+    }
+
+    /// Determine the relevant [packageRange] and [pubspecInformation] depending
+    /// on the type of package.
+    if (hasGitOptions) {
+      dynamic git;
+
+      if (gitUrl == null) {
+        usageException('The `--git-url` is required for git dependencies.');
+      }
+
+      /// Process the git options to return the simplest representation to be
+      /// added to the pubspec.
+      if (gitRef == null && gitPath == null) {
+        git = gitUrl;
+      } else {
+        git = {'url': gitUrl, 'ref': gitRef, 'path': gitPath};
+        git.removeWhere((key, value) => value == null);
+      }
+
+      packageRange = cache.sources['git']
+          .parseRef(packageName, git)
+          .withConstraint(constraint ?? VersionConstraint.any);
+      pubspecInformation = {'git': git};
+    } else if (path != null) {
+      packageRange = cache.sources['path']
+          .parseRef(packageName, path, containingPath: entrypoint.pubspecPath)
+          .withConstraint(constraint ?? VersionConstraint.any);
+      pubspecInformation = {'path': path};
+    } else if (sdk != null) {
+      packageRange = cache.sources['sdk']
+          .parseRef(packageName, sdk)
+          .withConstraint(constraint ?? VersionConstraint.any);
+      pubspecInformation = {'sdk': sdk};
+    } else {
+      final hostInfo =
+          hasHostOptions ? {'url': hostUrl, 'name': packageName} : null;
+
+      if (hostInfo == null) {
+        pubspecInformation = constraint?.toString();
+      } else {
+        pubspecInformation = {'hosted': hostInfo};
+      }
+
+      packageRange = PackageRange(packageName, cache.sources['hosted'],
+          constraint ?? VersionConstraint.any, hostInfo ?? packageName);
+    }
+
+    if (pubspecInformation is Map && constraint != null) {
+      /// We cannot simply assign the value of version since it is likely that
+      /// [pubspecInformation] takes on the type
+      /// [Map<String, Map<String, String>>]
+      pubspecInformation = {
+        ...pubspecInformation,
+        'version': constraint.toString()
+      };
+    }
+
+    return Pair(packageRange, pubspecInformation);
+  }
+
+  /// Writes the changes to the pubspec file.
+  void _updatePubspec(PackageId resultPackage,
+      Pair<PackageRange, dynamic> packageInformation, bool isDevelopment) {
+    ArgumentError.checkNotNull(resultPackage, 'resultPackage');
+    ArgumentError.checkNotNull(packageInformation, 'pubspecInformation');
+
+    final package = packageInformation.first;
+    var pubspecInformation = packageInformation.last;
+
+    if ((sdk != null || hasHostOptions) &&
+        pubspecInformation is Map &&
+        pubspecInformation['version'] == null) {
+      /// We cannot simply assign the value of version since it is likely that
+      /// [pubspecInformation] takes on the type
+      /// [Map<String, Map<String, String>>]
+      pubspecInformation = {
+        ...pubspecInformation,
+        'version': '^${resultPackage.version}'
+      };
+    }
+
+    final dependencyKey = isDevelopment ? 'dev_dependencies' : 'dependencies';
+    final packagePath = [dependencyKey, package.name];
+
+    final yamlEditor = YamlEditor(readTextFile(entrypoint.pubspecPath));
+
+    /// Handle situations where the user might not have the dependencies or
+    /// dev_dependencies map.
+    if (yamlEditor.parseAt([dependencyKey], orElse: () => null)?.value ==
+        null) {
+      yamlEditor.update([dependencyKey],
+          {package.name: pubspecInformation ?? '^${resultPackage.version}'});
+    } else {
+      yamlEditor.update(
+          packagePath, pubspecInformation ?? '^${resultPackage.version}');
+    }
+
+    /// Remove the package from dev_dependencies if we are adding it to
+    /// dependencies. Refer to [_addPackageToPubspec] for additional discussion.
+    if (!isDevelopment &&
+        yamlEditor.parseAt(['dev_dependencies', package.name],
+                orElse: () => null) !=
+            null) {
+      yamlEditor.remove(['dev_dependencies', package.name]);
+    }
+
+    /// Windows line endings are already handled by [yamlEditor]
+    writeTextFile(entrypoint.pubspecPath, yamlEditor.toString());
+  }
+}
diff --git a/lib/src/command_runner.dart b/lib/src/command_runner.dart
index 8065b78..4a3f547 100644
--- a/lib/src/command_runner.dart
+++ b/lib/src/command_runner.dart
@@ -11,6 +11,7 @@
 import 'package:path/path.dart' as p;
 
 import 'command.dart' show pubCommandAliases, lineLength;
+import 'command/add.dart';
 import 'command/build.dart';
 import 'command/cache.dart';
 import 'command/deps.dart';
@@ -104,6 +105,7 @@
     argParser.addFlag('verbose',
         abbr: 'v', negatable: false, help: 'Shortcut for "--verbosity=all".');
 
+    addCommand(AddCommand());
     addCommand(BuildCommand());
     addCommand(CacheCommand());
     addCommand(DepsCommand());
diff --git a/pubspec.yaml b/pubspec.yaml
index 30b464d..076e303 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -28,6 +28,7 @@
   source_span: ^1.4.0
   stack_trace: ^1.0.0
   yaml: ^2.2.0
+  yaml_edit: ^1.0.1
 
 dev_dependencies:
   shelf_test_handler: ^1.0.0
diff --git a/test/add/common/add_test.dart b/test/add/common/add_test.dart
new file mode 100644
index 0000000..737dfa6
--- /dev/null
+++ b/test/add/common/add_test.dart
@@ -0,0 +1,809 @@
+// Copyright (c) 2020, 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' show File;
+
+import 'package:path/path.dart' as p;
+import 'package:test/test.dart';
+
+import 'package:pub/src/exit_codes.dart' as exit_codes;
+
+import '../../descriptor.dart' as d;
+import '../../descriptor/yaml.dart';
+import '../../test_pub.dart';
+
+void main() {
+  test('URL encodes the package name', () async {
+    await serveNoPackages();
+
+    await d.appDir({}).create();
+
+    await pubAdd(
+        args: ['bad name!:1.2.3'],
+        error: allOf([
+          contains(
+              "Because myapp depends on bad name! any which doesn't exist (could "
+              'not find package bad name! at http://localhost:'),
+          contains('), version solving failed.')
+        ]),
+        exitCode: exit_codes.DATA);
+
+    await d.appDir({}).validate();
+
+    await d.dir(appPath, [
+      d.nothing('.dart_tool/package_config.json'),
+      d.nothing('pubspec.lock'),
+      d.nothing('.packages'),
+    ]).validate();
+  });
+
+  group('normally', () {
+    test('adds a package from a pub server', () async {
+      await servePackages((builder) => builder.serve('foo', '1.2.3'));
+
+      await d.appDir({}).create();
+
+      await pubAdd(args: ['foo:1.2.3']);
+
+      await d.cacheDir({'foo': '1.2.3'}).validate();
+      await d.appPackagesFile({'foo': '1.2.3'}).validate();
+      await d.appDir({'foo': '1.2.3'}).validate();
+    });
+
+    test('dry run does not actually add the package or modify the pubspec',
+        () async {
+      await servePackages((builder) => builder.serve('foo', '1.2.3'));
+
+      await d.appDir({}).create();
+
+      await pubAdd(
+          args: ['foo:1.2.3', '--dry-run'],
+          output: allOf([
+            contains('Would change 1 dependency'),
+            contains('+ foo 1.2.3')
+          ]));
+
+      await d.appDir({}).validate();
+      await d.dir(appPath, [
+        d.nothing('.dart_tool/package_config.json'),
+        d.nothing('pubspec.lock'),
+        d.nothing('.packages'),
+      ]).validate();
+    });
+
+    test(
+        'adds a package from a pub server even when dependencies key does not exist',
+        () async {
+      await servePackages((builder) => builder.serve('foo', '1.2.3'));
+
+      await d.dir(appPath, [
+        d.pubspec({'name': 'myapp'})
+      ]).create();
+
+      await pubAdd(args: ['foo:1.2.3']);
+
+      await d.cacheDir({'foo': '1.2.3'}).validate();
+      await d.appPackagesFile({'foo': '1.2.3'}).validate();
+      await d.appDir({'foo': '1.2.3'}).validate();
+    });
+
+    group('warns user to use pub upgrade if package exists', () {
+      test('if package is added without a version constraint', () async {
+        await servePackages((builder) {
+          builder.serve('foo', '1.2.3');
+          builder.serve('foo', '1.2.2');
+        });
+
+        await d.appDir({'foo': '1.2.2'}).create();
+
+        await pubAdd(
+            args: ['foo'],
+            exitCode: exit_codes.USAGE,
+            error:
+                contains('"foo" is already in "dependencies". Use "pub upgrade '
+                    'foo" to upgrade to a later\nversion!'));
+
+        await d.appDir({'foo': '1.2.2'}).validate();
+      });
+
+      test('if package is added with a specific version constraint', () async {
+        await servePackages((builder) {
+          builder.serve('foo', '1.2.3');
+          builder.serve('foo', '1.2.2');
+        });
+
+        await d.appDir({'foo': '1.2.2'}).create();
+
+        await pubAdd(
+            args: ['foo:1.2.3'],
+            exitCode: exit_codes.USAGE,
+            error:
+                contains('"foo" is already in "dependencies". Use "pub upgrade '
+                    'foo" to upgrade to a later\nversion!'));
+
+        await d.appDir({'foo': '1.2.2'}).validate();
+      });
+
+      test('if package is added with a version constraint range', () async {
+        await servePackages((builder) {
+          builder.serve('foo', '1.2.3');
+          builder.serve('foo', '1.2.2');
+        });
+
+        await d.appDir({'foo': '1.2.2'}).create();
+
+        await pubAdd(
+            args: ['foo:>=1.2.2'],
+            exitCode: exit_codes.USAGE,
+            error:
+                contains('"foo" is already in "dependencies". Use "pub upgrade '
+                    'foo" to upgrade to a later\nversion!'));
+
+        await d.appDir({'foo': '1.2.2'}).validate();
+      });
+    });
+
+    test('removes dev_dependency and add to normal dependency', () async {
+      await servePackages((builder) {
+        builder.serve('foo', '1.2.3');
+        builder.serve('foo', '1.2.2');
+      });
+
+      await d.dir(appPath, [
+        d.pubspec({
+          'name': 'myapp',
+          'dependencies': {},
+          'dev_dependencies': {'foo': '1.2.2'}
+        })
+      ]).create();
+
+      await pubAdd(
+          args: ['foo:1.2.3'],
+          output: contains(
+              '"foo" was found in dev_dependencies. Removing "foo" and '
+              'adding it to dependencies instead.'));
+
+      await d.cacheDir({'foo': '1.2.3'}).validate();
+      await d.appPackagesFile({'foo': '1.2.3'}).validate();
+      await d.dir(appPath, [
+        d.pubspec({
+          'name': 'myapp',
+          'dependencies': {'foo': '1.2.3'},
+          'dev_dependencies': {}
+        })
+      ]).validate();
+    });
+
+    group('dependency override', () {
+      test('passes if package does not specify a range', () async {
+        await servePackages((builder) {
+          builder.serve('foo', '1.2.3');
+          builder.serve('foo', '1.2.2');
+        });
+
+        await d.dir(appPath, [
+          d.pubspec({
+            'name': 'myapp',
+            'dependencies': {},
+            'dependency_overrides': {'foo': '1.2.2'}
+          })
+        ]).create();
+
+        await pubAdd(args: ['foo']);
+
+        await d.cacheDir({'foo': '1.2.2'}).validate();
+        await d.appPackagesFile({'foo': '1.2.2'}).validate();
+        await d.dir(appPath, [
+          d.pubspec({
+            'name': 'myapp',
+            'dependencies': {'foo': '^1.2.2'},
+            'dependency_overrides': {'foo': '1.2.2'}
+          })
+        ]).validate();
+      });
+
+      test('passes if constraint matches git dependency override', () async {
+        await servePackages((builder) {
+          builder.serve('foo', '1.2.3');
+        });
+
+        await d.git('foo.git',
+            [d.libDir('foo'), d.libPubspec('foo', '1.2.3')]).create();
+
+        await d.dir(appPath, [
+          d.pubspec({
+            'name': 'myapp',
+            'dependencies': {},
+            'dependency_overrides': {
+              'foo': {'git': '../foo.git'}
+            }
+          })
+        ]).create();
+
+        await pubAdd(args: ['foo:1.2.3']);
+
+        await d.dir(appPath, [
+          d.pubspec({
+            'name': 'myapp',
+            'dependencies': {'foo': '1.2.3'},
+            'dependency_overrides': {
+              'foo': {'git': '../foo.git'}
+            }
+          })
+        ]).validate();
+      });
+
+      test('passes if constraint matches path dependency override', () async {
+        await servePackages((builder) {
+          builder.serve('foo', '1.2.2');
+        });
+        await d.dir(
+            'foo', [d.libDir('foo'), d.libPubspec('foo', '1.2.2')]).create();
+
+        await d.dir(appPath, [
+          d.pubspec({
+            'name': 'myapp',
+            'dependencies': {},
+            'dependency_overrides': {
+              'foo': {'path': '../foo'}
+            }
+          })
+        ]).create();
+
+        await pubAdd(args: ['foo:1.2.2']);
+
+        await d.dir(appPath, [
+          d.pubspec({
+            'name': 'myapp',
+            'dependencies': {'foo': '1.2.2'},
+            'dependency_overrides': {
+              'foo': {'path': '../foo'}
+            }
+          })
+        ]).validate();
+      });
+
+      test('fails with bad version constraint', () async {
+        await servePackages((builder) {
+          builder.serve('foo', '1.2.3');
+        });
+
+        await d.dir(appPath, [
+          d.pubspec({'name': 'myapp', 'dependencies': {}})
+        ]).create();
+
+        await pubAdd(
+            args: ['foo:one-two-three'],
+            exitCode: exit_codes.USAGE,
+            error: contains('Invalid version constraint: Could '
+                'not parse version "one-two-three".'));
+
+        await d.dir(appPath, [
+          d.pubspec({'name': 'myapp', 'dependencies': {}}),
+          d.nothing('.dart_tool/package_config.json'),
+          d.nothing('pubspec.lock'),
+          d.nothing('.packages'),
+        ]).validate();
+      });
+
+      test('fails if constraint does not match override', () async {
+        await servePackages((builder) {
+          builder.serve('foo', '1.2.3');
+          builder.serve('foo', '1.2.2');
+        });
+
+        await d.dir(appPath, [
+          d.pubspec({
+            'name': 'myapp',
+            'dependencies': {},
+            'dependency_overrides': {'foo': '1.2.2'}
+          })
+        ]).create();
+
+        await pubAdd(
+            args: ['foo:1.2.3'],
+            exitCode: exit_codes.DATA,
+            error: contains(
+                '"foo" resolved to "1.2.2" which does not satisfy constraint '
+                '"1.2.3". This could be caused by "dependency_overrides".'));
+
+        await d.dir(appPath, [
+          d.pubspec({
+            'name': 'myapp',
+            'dependencies': {},
+            'dependency_overrides': {'foo': '1.2.2'}
+          }),
+          d.nothing('.dart_tool/package_config.json'),
+          d.nothing('pubspec.lock'),
+          d.nothing('.packages'),
+        ]).validate();
+      });
+
+      test('fails if constraint matches git dependency override', () async {
+        await servePackages((builder) {
+          builder.serve('foo', '1.2.3');
+        });
+
+        await d.git('foo.git',
+            [d.libDir('foo'), d.libPubspec('foo', '1.0.0')]).create();
+
+        await d.dir(appPath, [
+          d.pubspec({
+            'name': 'myapp',
+            'dependencies': {},
+            'dependency_overrides': {
+              'foo': {'git': '../foo.git'}
+            }
+          })
+        ]).create();
+
+        await pubAdd(
+            args: ['foo:1.2.3'],
+            exitCode: exit_codes.DATA,
+            error: contains(
+                '"foo" resolved to "1.0.0" which does not satisfy constraint '
+                '"1.2.3". This could be caused by "dependency_overrides".'));
+
+        await d.dir(appPath, [
+          d.pubspec({
+            'name': 'myapp',
+            'dependencies': {},
+            'dependency_overrides': {
+              'foo': {'git': '../foo.git'}
+            }
+          }),
+          d.nothing('.dart_tool/package_config.json'),
+          d.nothing('pubspec.lock'),
+          d.nothing('.packages'),
+        ]).validate();
+      });
+
+      test('fails if constraint does not match path dependency override',
+          () async {
+        await servePackages((builder) {
+          builder.serve('foo', '1.2.2');
+        });
+        await d.dir(
+            'foo', [d.libDir('foo'), d.libPubspec('foo', '1.0.0')]).create();
+
+        await d.dir(appPath, [
+          d.pubspec({
+            'name': 'myapp',
+            'dependencies': {},
+            'dependency_overrides': {
+              'foo': {'path': '../foo'}
+            }
+          })
+        ]).create();
+
+        await pubAdd(
+            args: ['foo:1.2.2'],
+            exitCode: exit_codes.DATA,
+            error: contains(
+                '"foo" resolved to "1.0.0" which does not satisfy constraint '
+                '"1.2.2". This could be caused by "dependency_overrides".'));
+
+        await d.dir(appPath, [
+          d.pubspec({
+            'name': 'myapp',
+            'dependencies': {},
+            'dependency_overrides': {
+              'foo': {'path': '../foo'}
+            }
+          }),
+          d.nothing('.dart_tool/package_config.json'),
+          d.nothing('pubspec.lock'),
+          d.nothing('.packages'),
+        ]).validate();
+      });
+    });
+  });
+
+  group('--dev', () {
+    test('--dev adds packages to dev_dependencies instead', () async {
+      await servePackages((builder) => builder.serve('foo', '1.2.3'));
+
+      await d.dir(appPath, [
+        d.pubspec({'name': 'myapp', 'dev_dependencies': {}})
+      ]).create();
+
+      await pubAdd(args: ['--dev', 'foo:1.2.3']);
+
+      await d.appPackagesFile({'foo': '1.2.3'}).validate();
+
+      await d.dir(appPath, [
+        d.pubspec({
+          'name': 'myapp',
+          'dev_dependencies': {'foo': '1.2.3'}
+        })
+      ]).validate();
+    });
+
+    group('warns user to use pub upgrade if package exists', () {
+      test('if package is added without a version constraint', () async {
+        await servePackages((builder) {
+          builder.serve('foo', '1.2.3');
+          builder.serve('foo', '1.2.2');
+        });
+
+        await d.dir(appPath, [
+          d.pubspec({
+            'name': 'myapp',
+            'dev_dependencies': {'foo': '1.2.2'}
+          })
+        ]).create();
+
+        await pubAdd(
+            args: ['foo', '--dev'],
+            exitCode: exit_codes.USAGE,
+            error: contains(
+                '"foo" is already in "dev_dependencies". Use "pub upgrade '
+                'foo" to upgrade to a\nlater version!'));
+
+        await d.dir(appPath, [
+          d.pubspec({
+            'name': 'myapp',
+            'dev_dependencies': {'foo': '1.2.2'}
+          })
+        ]).validate();
+      });
+
+      test('if package is added with a specific version constraint', () async {
+        await servePackages((builder) {
+          builder.serve('foo', '1.2.3');
+          builder.serve('foo', '1.2.2');
+        });
+
+        await d.dir(appPath, [
+          d.pubspec({
+            'name': 'myapp',
+            'dev_dependencies': {'foo': '1.2.2'}
+          })
+        ]).create();
+
+        await pubAdd(
+            args: ['foo:1.2.3', '--dev'],
+            exitCode: exit_codes.USAGE,
+            error: contains(
+                '"foo" is already in "dev_dependencies". Use "pub upgrade '
+                'foo" to upgrade to a\nlater version!'));
+
+        await d.dir(appPath, [
+          d.pubspec({
+            'name': 'myapp',
+            'dev_dependencies': {'foo': '1.2.2'}
+          })
+        ]).validate();
+      });
+
+      test('if package is added with a version constraint range', () async {
+        await servePackages((builder) {
+          builder.serve('foo', '1.2.3');
+          builder.serve('foo', '1.2.2');
+        });
+
+        await d.dir(appPath, [
+          d.pubspec({
+            'name': 'myapp',
+            'dev_dependencies': {'foo': '1.2.2'}
+          })
+        ]).create();
+
+        await pubAdd(
+            args: ['foo:>=1.2.2', '--dev'],
+            exitCode: exit_codes.USAGE,
+            error: contains(
+                '"foo" is already in "dev_dependencies". Use "pub upgrade '
+                'foo" to upgrade to a\nlater version!'));
+
+        await d.dir(appPath, [
+          d.pubspec({
+            'name': 'myapp',
+            'dev_dependencies': {'foo': '1.2.2'}
+          })
+        ]).validate();
+      });
+    });
+
+    group('dependency override', () {
+      test('passes if package does not specify a range', () async {
+        await servePackages((builder) {
+          builder.serve('foo', '1.2.3');
+          builder.serve('foo', '1.2.2');
+        });
+
+        await d.dir(appPath, [
+          d.pubspec({
+            'name': 'myapp',
+            'dev_dependencies': {},
+            'dependency_overrides': {'foo': '1.2.2'}
+          })
+        ]).create();
+
+        await pubAdd(args: ['foo', '--dev']);
+
+        await d.cacheDir({'foo': '1.2.2'}).validate();
+        await d.appPackagesFile({'foo': '1.2.2'}).validate();
+        await d.dir(appPath, [
+          d.pubspec({
+            'name': 'myapp',
+            'dev_dependencies': {'foo': '^1.2.2'},
+            'dependency_overrides': {'foo': '1.2.2'}
+          })
+        ]).validate();
+      });
+
+      test('passes if constraint is git dependency', () async {
+        await servePackages((builder) {
+          builder.serve('foo', '1.2.3');
+        });
+
+        await d.git('foo.git',
+            [d.libDir('foo'), d.libPubspec('foo', '1.2.3')]).create();
+
+        await d.dir(appPath, [
+          d.pubspec({
+            'name': 'myapp',
+            'dev_dependencies': {},
+            'dependency_overrides': {
+              'foo': {'git': '../foo.git'}
+            }
+          })
+        ]).create();
+
+        await pubAdd(args: ['foo:1.2.3', '--dev']);
+
+        await d.dir(appPath, [
+          d.pubspec({
+            'name': 'myapp',
+            'dev_dependencies': {'foo': '1.2.3'},
+            'dependency_overrides': {
+              'foo': {'git': '../foo.git'}
+            }
+          })
+        ]).validate();
+      });
+
+      test('passes if constraint matches path dependency override', () async {
+        await servePackages((builder) {
+          builder.serve('foo', '1.2.2');
+        });
+        await d.dir(
+            'foo', [d.libDir('foo'), d.libPubspec('foo', '1.2.2')]).create();
+
+        await d.dir(appPath, [
+          d.pubspec({
+            'name': 'myapp',
+            'dev_dependencies': {},
+            'dependency_overrides': {
+              'foo': {'path': '../foo'}
+            }
+          })
+        ]).create();
+
+        await pubAdd(args: ['foo:1.2.2', '--dev']);
+
+        await d.dir(appPath, [
+          d.pubspec({
+            'name': 'myapp',
+            'dev_dependencies': {'foo': '1.2.2'},
+            'dependency_overrides': {
+              'foo': {'path': '../foo'}
+            }
+          })
+        ]).validate();
+      });
+
+      test('fails if constraint does not match override', () async {
+        await servePackages((builder) {
+          builder.serve('foo', '1.2.3');
+          builder.serve('foo', '1.2.2');
+        });
+
+        await d.dir(appPath, [
+          d.pubspec({
+            'name': 'myapp',
+            'dev_dependencies': {},
+            'dependency_overrides': {'foo': '1.2.2'}
+          })
+        ]).create();
+
+        await pubAdd(
+            args: ['foo:1.2.3', '--dev'],
+            exitCode: exit_codes.DATA,
+            error: contains(
+                '"foo" resolved to "1.2.2" which does not satisfy constraint '
+                '"1.2.3". This could be caused by "dependency_overrides".'));
+
+        await d.dir(appPath, [
+          d.pubspec({
+            'name': 'myapp',
+            'dev_dependencies': {},
+            'dependency_overrides': {'foo': '1.2.2'}
+          }),
+          d.nothing('.dart_tool/package_config.json'),
+          d.nothing('pubspec.lock'),
+          d.nothing('.packages'),
+        ]).validate();
+      });
+
+      test('fails if constraint matches git dependency override', () async {
+        await servePackages((builder) {
+          builder.serve('foo', '1.2.3');
+        });
+
+        await d.git('foo.git',
+            [d.libDir('foo'), d.libPubspec('foo', '1.0.0')]).create();
+
+        await d.dir(appPath, [
+          d.pubspec({
+            'name': 'myapp',
+            'dev_dependencies': {},
+            'dependency_overrides': {
+              'foo': {'git': '../foo.git'}
+            }
+          })
+        ]).create();
+
+        await pubAdd(
+            args: ['foo:1.2.3'],
+            exitCode: exit_codes.DATA,
+            error: contains(
+                '"foo" resolved to "1.0.0" which does not satisfy constraint '
+                '"1.2.3". This could be caused by "dependency_overrides".'));
+
+        await d.dir(appPath, [
+          d.pubspec({
+            'name': 'myapp',
+            'dev_dependencies': {},
+            'dependency_overrides': {
+              'foo': {'git': '../foo.git'}
+            }
+          }),
+          d.nothing('.dart_tool/package_config.json'),
+          d.nothing('pubspec.lock'),
+          d.nothing('.packages'),
+        ]).validate();
+      });
+
+      test('fails if constraint does not match path dependency override',
+          () async {
+        await servePackages((builder) {
+          builder.serve('foo', '1.2.2');
+        });
+        await d.dir(
+            'foo', [d.libDir('foo'), d.libPubspec('foo', '1.0.0')]).create();
+
+        await d.dir(appPath, [
+          d.pubspec({
+            'name': 'myapp',
+            'dev_dependencies': {},
+            'dependency_overrides': {
+              'foo': {'path': '../foo'}
+            }
+          })
+        ]).create();
+
+        await pubAdd(
+            args: ['foo:1.2.2', '--dev'],
+            exitCode: exit_codes.DATA,
+            error: contains(
+                '"foo" resolved to "1.0.0" which does not satisfy constraint '
+                '"1.2.2". This could be caused by "dependency_overrides".'));
+
+        await d.dir(appPath, [
+          d.pubspec({
+            'name': 'myapp',
+            'dev_dependencies': {},
+            'dependency_overrides': {
+              'foo': {'path': '../foo'}
+            }
+          }),
+          d.nothing('.dart_tool/package_config.json'),
+          d.nothing('pubspec.lock'),
+          d.nothing('.packages'),
+        ]).validate();
+      });
+    });
+
+    test(
+        'prints information saying that package is already a dependency if it '
+        'already exists and exits with a usage exception', () async {
+      await servePackages((builder) {
+        builder.serve('foo', '1.2.3');
+        builder.serve('foo', '1.2.2');
+      });
+
+      await d.dir(appPath, [
+        d.pubspec({
+          'name': 'myapp',
+          'dependencies': {'foo': '1.2.2'},
+          'dev_dependencies': {}
+        })
+      ]).create();
+
+      await pubAdd(
+          args: ['foo:1.2.3', '--dev'],
+          error: contains('"foo" is already in "dependencies". Use '
+              '"pub remove foo" to remove it before\nadding it to '
+              '"dev_dependencies"'),
+          exitCode: exit_codes.USAGE);
+
+      await d.dir(appPath, [
+        d.pubspec({
+          'name': 'myapp',
+          'dependencies': {'foo': '1.2.2'},
+          'dev_dependencies': {}
+        }),
+        d.nothing('.dart_tool/package_config.json'),
+        d.nothing('pubspec.lock'),
+        d.nothing('.packages'),
+      ]).validate();
+    });
+  });
+
+  /// Differs from the previous test because this tests YAML in flow format.
+  test('adds to empty ', () async {
+    await servePackages((builder) {
+      builder.serve('bar', '1.0.0');
+    });
+
+    final initialPubspec = YamlDescriptor('pubspec.yaml', '''
+      name: myapp
+      dependencies:
+''');
+    await d.dir(appPath, [initialPubspec]).create();
+
+    await pubGet();
+
+    await pubAdd(args: ['bar']);
+
+    final finalPubspec = YamlDescriptor('pubspec.yaml', '''
+      name: myapp
+      dependencies: 
+        bar: ^1.0.0''');
+    await d.dir(appPath, [finalPubspec]).validate();
+    final fullPath = p.join(d.sandbox, appPath, 'pubspec.yaml');
+
+    expect(File(fullPath).existsSync(), true);
+
+    final contents = File(fullPath).readAsStringSync();
+    expect(contents, await finalPubspec.read());
+  });
+
+  test('preserves comments', () async {
+    await servePackages((builder) {
+      builder.serve('bar', '1.0.0');
+      builder.serve('foo', '1.0.0');
+    });
+
+    final initialPubspec = YamlDescriptor('pubspec.yaml', '''
+      name: myapp
+      dependencies: # comment A
+          # comment B
+          foo: 1.0.0 # comment C
+        # comment D
+    ''');
+    await d.dir(appPath, [initialPubspec]).create();
+
+    await pubGet();
+
+    await pubAdd(args: ['bar']);
+
+    final finalPubspec = YamlDescriptor('pubspec.yaml', '''
+      name: myapp
+      dependencies: # comment A
+          # comment B
+          bar: ^1.0.0
+          foo: 1.0.0 # comment C
+        # comment D
+    ''');
+    await d.dir(appPath, [finalPubspec]).validate();
+    final fullPath = p.join(d.sandbox, appPath, 'pubspec.yaml');
+
+    expect(File(fullPath).existsSync(), true);
+
+    final contents = File(fullPath).readAsStringSync();
+    expect(contents, await finalPubspec.read());
+  });
+}
diff --git a/test/add/common/invalid_options.dart b/test/add/common/invalid_options.dart
new file mode 100644
index 0000000..9b3a8f0
--- /dev/null
+++ b/test/add/common/invalid_options.dart
@@ -0,0 +1,112 @@
+// Copyright (c) 2020, 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 'package:pub/src/exit_codes.dart' as exit_codes;
+import 'package:test/test.dart';
+
+import '../../descriptor.dart' as d;
+import '../../test_pub.dart';
+
+void main() {
+  test('cannot use both --path and --git-<option> flags', () async {
+    ensureGit();
+
+    await d.git(
+        'foo.git', [d.libDir('foo'), d.libPubspec('foo', '1.0.0')]).create();
+    await d
+        .dir('bar', [d.libDir('bar'), d.libPubspec('foo', '0.0.1')]).create();
+
+    await d.appDir({}).create();
+
+    await pubAdd(
+        args: ['foo', '--git-url', '../foo.git', '--path', '../bar'],
+        error: allOf([
+          contains('Packages can only have one source, pub add flags '
+              '"--git-url" and "--path" are'),
+          contains('conflicting.')
+        ]),
+        exitCode: exit_codes.USAGE);
+
+    await d.appDir({}).validate();
+    await d.dir(appPath, [
+      d.nothing('.dart_tool/package_config.json'),
+      d.nothing('pubspec.lock'),
+      d.nothing('.packages'),
+    ]).validate();
+  });
+
+  test('cannot use both --path and --host-<option> flags', () async {
+    // Make the default server serve errors. Only the custom server should
+    // be accessed.
+    await serveErrors();
+
+    final server = await PackageServer.start((builder) {
+      builder.serve('foo', '1.2.3');
+    });
+
+    await d
+        .dir('bar', [d.libDir('bar'), d.libPubspec('foo', '0.0.1')]).create();
+    await d.appDir({}).create();
+
+    await pubAdd(
+        args: [
+          'foo',
+          '--hosted-url',
+          'http://localhost:${server.port}',
+          '--path',
+          '../bar'
+        ],
+        error: allOf([
+          contains('Packages can only have one source, pub add flags '
+              '"--hosted-url" and "--path" are'),
+          contains('conflicting.')
+        ]),
+        exitCode: exit_codes.USAGE);
+
+    await d.appDir({}).validate();
+    await d.dir(appPath, [
+      d.nothing('.dart_tool/package_config.json'),
+      d.nothing('pubspec.lock'),
+      d.nothing('.packages'),
+    ]).validate();
+  });
+
+  test('cannot use both --hosted-url and --git-<option> flags', () async {
+    // Make the default server serve errors. Only the custom server should
+    // be accessed.
+    await serveErrors();
+
+    final server = await PackageServer.start((builder) {
+      builder.serve('foo', '1.2.3');
+    });
+
+    ensureGit();
+
+    await d.git(
+        'foo.git', [d.libDir('foo'), d.libPubspec('foo', '1.0.0')]).create();
+    await d.appDir({}).create();
+
+    await pubAdd(
+        args: [
+          'foo',
+          '--hosted-url',
+          'http://localhost:${server.port}',
+          '--git-url',
+          '../foo.git'
+        ],
+        error: allOf([
+          contains('Packages can only have one source, pub add flags '
+              '"--git-url" and "--hosted-url"'),
+          contains('are conflicting.')
+        ]),
+        exitCode: exit_codes.USAGE);
+
+    await d.appDir({}).validate();
+    await d.dir(appPath, [
+      d.nothing('.dart_tool/package_config.json'),
+      d.nothing('pubspec.lock'),
+      d.nothing('.packages'),
+    ]).validate();
+  });
+}
diff --git a/test/add/common/version_constraint_test.dart b/test/add/common/version_constraint_test.dart
new file mode 100644
index 0000000..d865727
--- /dev/null
+++ b/test/add/common/version_constraint_test.dart
@@ -0,0 +1,151 @@
+// Copyright (c) 2020, 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 'package:pub/src/exit_codes.dart' as exit_codes;
+import 'package:test/test.dart';
+
+import '../../descriptor.dart' as d;
+import '../../test_pub.dart';
+
+void main() {
+  test('allows empty version constraint', () async {
+    await servePackages((builder) {
+      builder.serve('foo', '0.2.3');
+      builder.serve('foo', '1.0.1');
+      builder.serve('foo', '1.2.3');
+      builder.serve('foo', '2.0.0-dev');
+      builder.serve('foo', '1.3.4-dev');
+    });
+
+    await d.appDir({}).create();
+
+    await pubAdd(args: ['foo']);
+
+    await d.cacheDir({'foo': '1.2.3'}).validate();
+    await d.appPackagesFile({'foo': '1.2.3'}).validate();
+    await d.appDir({'foo': '^1.2.3'}).validate();
+  });
+
+  test('allows specific version constraint', () async {
+    await servePackages((builder) => builder.serve('foo', '1.2.3'));
+
+    await d.appDir({}).create();
+
+    await pubAdd(args: ['foo:1.2.3']);
+
+    await d.cacheDir({'foo': '1.2.3'}).validate();
+    await d.appPackagesFile({'foo': '1.2.3'}).validate();
+    await d.appDir({'foo': '1.2.3'}).validate();
+  });
+
+  test('allows specific pre-release version constraint', () async {
+    await servePackages((builder) => builder.serve('foo', '1.2.3-dev'));
+
+    await d.appDir({}).create();
+
+    await pubAdd(args: ['foo:1.2.3-dev']);
+
+    await d.cacheDir({'foo': '1.2.3-dev'}).validate();
+    await d.appPackagesFile({'foo': '1.2.3-dev'}).validate();
+    await d.appDir({'foo': '1.2.3-dev'}).validate();
+  });
+
+  test('allows the "any" version constraint', () async {
+    await servePackages((builder) {
+      builder.serve('foo', '0.2.3');
+      builder.serve('foo', '1.0.1');
+      builder.serve('foo', '1.2.3');
+      builder.serve('foo', '2.0.0-dev');
+      builder.serve('foo', '1.3.4-dev');
+    });
+
+    await d.appDir({}).create();
+
+    await pubAdd(args: ['foo:any']);
+
+    await d.cacheDir({'foo': '1.2.3'}).validate();
+    await d.appPackagesFile({'foo': '1.2.3'}).validate();
+    await d.appDir({'foo': 'any'}).validate();
+  });
+
+  test('allows version constraint range', () async {
+    await servePackages((builder) => builder.serve('foo', '1.2.3'));
+
+    await d.appDir({}).create();
+
+    await pubAdd(args: ['foo:>1.2.0 <2.0.0']);
+
+    await d.cacheDir({'foo': '1.2.3'}).validate();
+    await d.appPackagesFile({'foo': '1.2.3'}).validate();
+    await d.appDir({'foo': '>1.2.0 <2.0.0'}).validate();
+  });
+
+  test(
+      'empty constraint allows it to choose the latest version not in conflict',
+      () async {
+    await servePackages((builder) {
+      builder.serve('foo', '0.1.0');
+      builder.serve('foo', '1.2.3', deps: {'bar': '2.0.4'});
+      builder.serve('bar', '2.0.3');
+      builder.serve('bar', '2.0.4');
+    });
+
+    await d.appDir({'bar': '2.0.3'}).create();
+
+    await pubAdd(args: ['foo']);
+
+    await d.appDir({'foo': '^0.1.0', 'bar': '2.0.3'}).validate();
+
+    await d.cacheDir({'foo': '0.1.0', 'bar': '2.0.3'}).validate();
+    await d.appPackagesFile({'foo': '0.1.0', 'bar': '2.0.3'}).validate();
+  });
+
+  group('does not update pubspec if no available version found', () {
+    test('simple', () async {
+      await servePackages((builder) => builder.serve('foo', '1.0.3'));
+
+      await d.appDir({}).create();
+
+      await pubAdd(
+          args: ['foo:>1.2.0 <2.0.0'],
+          error: contains(
+              "Because myapp depends on foo >1.2.0 <2.0.0 which doesn't "
+              'match any versions, version solving failed.'),
+          exitCode: exit_codes.DATA);
+
+      await d.appDir({}).validate();
+      await d.dir(appPath, [
+        // The lockfile should not be created.
+        d.nothing('pubspec.lock'),
+        // The ".packages" file should not have been created.
+        d.nothing('.packages'),
+      ]).validate();
+    });
+
+    test('transitive', () async {
+      await servePackages((builder) {
+        builder.serve('foo', '1.2.3', deps: {'bar': '2.0.4'});
+        builder.serve('bar', '2.0.3');
+        builder.serve('bar', '2.0.4');
+      });
+
+      await d.appDir({'bar': '2.0.3'}).create();
+
+      await pubAdd(
+          args: ['foo:1.2.3'],
+          error: contains(
+              'Because every version of foo depends on bar 2.0.4 and myapp '
+              'depends on bar 2.0.3, foo is forbidden.'),
+          exitCode: exit_codes.DATA);
+
+      await d.appDir({'bar': '2.0.3'}).validate();
+      await d.dir(appPath, [
+        // The lockfile should not be created.
+        d.nothing('pubspec.lock'),
+        // The ".packages" file should not have been created.
+        d.nothing('.packages'),
+      ]).validate();
+    });
+  });
+}
diff --git a/test/add/common/version_resolution_test.dart b/test/add/common/version_resolution_test.dart
new file mode 100644
index 0000000..9d7d2a6
--- /dev/null
+++ b/test/add/common/version_resolution_test.dart
@@ -0,0 +1,89 @@
+// Copyright (c) 2020, 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 'package:test/test.dart';
+
+import '../../descriptor.dart' as d;
+import '../../test_pub.dart';
+
+/// This test suite attempts to cover the edge cases of version resolution
+/// with regards to transitive dependencies.
+void main() {
+  test('unlocks transitive dependencies', () async {
+    /// The server used to only have the foo v3.2.1 as the latest,
+    /// so pub get will create a pubspec.lock to foo 3.2.1
+    await servePackages((builder) {
+      builder.serve('foo', '3.2.1');
+      builder.serve('bar', '1.0.0', deps: {'foo': '^3.2.1'});
+    });
+
+    await d.appDir({'bar': '1.0.0'}).create();
+    await pubGet();
+
+    /// foo's package creator releases a newer version of foo, and we
+    /// want to test that this is what the user gets when they run
+    /// pub add foo.
+    globalPackageServer.add((builder) {
+      builder.serve('foo', '3.5.0');
+      builder.serve('foo', '3.1.0');
+      builder.serve('foo', '2.5.0');
+    });
+
+    await pubAdd(args: ['foo']);
+
+    await d.appDir({'foo': '^3.5.0', 'bar': '1.0.0'}).validate();
+    await d.cacheDir({'foo': '3.5.0', 'bar': '1.0.0'}).validate();
+    await d.appPackagesFile({'foo': '3.5.0', 'bar': '1.0.0'}).validate();
+  });
+
+  test('chooses the appropriate version to not break other dependencies',
+      () async {
+    /// The server used to only have the foo v3.2.1 as the latest,
+    /// so pub get will create a pubspec.lock to foo 3.2.1
+    await servePackages((builder) {
+      builder.serve('foo', '3.2.1');
+      builder.serve('bar', '1.0.0', deps: {'foo': '^3.2.1'});
+    });
+
+    await d.appDir({'bar': '1.0.0'}).create();
+    await pubGet();
+
+    globalPackageServer.add((builder) {
+      builder.serve('foo', '4.0.0');
+      builder.serve('foo', '2.0.0');
+    });
+
+    await pubAdd(args: ['foo']);
+
+    await d.appDir({'foo': '^3.2.1', 'bar': '1.0.0'}).validate();
+    await d.cacheDir({'foo': '3.2.1', 'bar': '1.0.0'}).validate();
+    await d.appPackagesFile({'foo': '3.2.1', 'bar': '1.0.0'}).validate();
+  });
+
+  test('may upgrade other packages if they allow a later version to be chosen',
+      () async {
+    /// The server used to only have the foo v3.2.1 as the latest,
+    /// so pub get will create a pubspec.lock to foo 3.2.1
+    await servePackages((builder) {
+      builder.serve('foo', '3.2.1');
+      builder.serve('bar', '1.0.0', deps: {'foo': '^3.2.1'});
+    });
+
+    await d.appDir({'bar': '^1.0.0'}).create();
+    await pubGet();
+
+    globalPackageServer.add((builder) {
+      builder.serve('foo', '5.0.0');
+      builder.serve('foo', '4.0.0');
+      builder.serve('foo', '2.0.0');
+      builder.serve('bar', '1.5.0', deps: {'foo': '^4.0.0'});
+    });
+
+    await pubAdd(args: ['foo']);
+
+    await d.appDir({'foo': '^4.0.0', 'bar': '^1.0.0'}).validate();
+    await d.cacheDir({'foo': '4.0.0', 'bar': '1.5.0'}).validate();
+    await d.appPackagesFile({'foo': '4.0.0', 'bar': '1.5.0'}).validate();
+  });
+}
diff --git a/test/add/git/git_test.dart b/test/add/git/git_test.dart
new file mode 100644
index 0000000..305b6a6
--- /dev/null
+++ b/test/add/git/git_test.dart
@@ -0,0 +1,146 @@
+// Copyright (c) 2020, 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 'package:pub/src/exit_codes.dart' as exit_codes;
+import 'package:test/test.dart';
+
+import '../../descriptor.dart' as d;
+import '../../test_pub.dart';
+
+void main() {
+  test('adds a package from git', () async {
+    ensureGit();
+
+    await d.git(
+        'foo.git', [d.libDir('foo'), d.libPubspec('foo', '1.0.0')]).create();
+
+    await d.appDir({}).create();
+
+    await pubAdd(args: ['foo', '--git-url', '../foo.git']);
+
+    await d.dir(cachePath, [
+      d.dir('git', [
+        d.dir('cache', [d.gitPackageRepoCacheDir('foo')]),
+        d.gitPackageRevisionCacheDir('foo')
+      ])
+    ]).validate();
+
+    await d.appDir({
+      'foo': {'git': '../foo.git'}
+    }).validate();
+  });
+
+  test('adds a package from git with version constraint', () async {
+    ensureGit();
+
+    await d.git(
+        'foo.git', [d.libDir('foo'), d.libPubspec('foo', '1.0.0')]).create();
+
+    await d.appDir({}).create();
+
+    await pubAdd(args: ['foo:1.0.0', '--git-url', '../foo.git']);
+
+    await d.dir(cachePath, [
+      d.dir('git', [
+        d.dir('cache', [d.gitPackageRepoCacheDir('foo')]),
+        d.gitPackageRevisionCacheDir('foo')
+      ])
+    ]).validate();
+
+    await d.appDir({
+      'foo': {'git': '../foo.git', 'version': '1.0.0'}
+    }).validate();
+  });
+
+  test('fails when adding with an invalid version constraint', () async {
+    ensureGit();
+
+    await d.git(
+        'foo.git', [d.libDir('foo'), d.libPubspec('foo', '1.0.0')]).create();
+
+    await d.appDir({}).create();
+
+    await pubAdd(
+        args: ['foo:2.0.0', '--git-url', '../foo.git'],
+        error: equalsIgnoringWhitespace(
+            'Because myapp depends on foo 2.0.0 from git which doesn\'t match '
+            'any versions, version solving failed.'),
+        exitCode: exit_codes.DATA);
+
+    await d.appDir({}).validate();
+    await d.dir(appPath, [
+      d.nothing('.dart_tool/package_config.json'),
+      d.nothing('pubspec.lock'),
+      d.nothing('.packages'),
+    ]).validate();
+  });
+
+  test('fails when adding from an invalid url', () async {
+    ensureGit();
+
+    await d.appDir({}).create();
+
+    await pubAdd(
+        args: ['foo', '--git-url', '../foo.git'],
+        error: contains('Unable to resolve package "foo" with the given '
+            'git parameters'),
+        exitCode: exit_codes.DATA);
+
+    await d.appDir({}).validate();
+    await d.dir(appPath, [
+      d.nothing('.dart_tool/package_config.json'),
+      d.nothing('pubspec.lock'),
+      d.nothing('.packages'),
+    ]).validate();
+  });
+
+  test('fails if git-url is not declared', () async {
+    ensureGit();
+
+    await d.appDir({}).create();
+
+    await pubAdd(
+        args: ['foo', '--git-ref', 'master'],
+        error: contains('The `--git-url` is required for git dependencies.'),
+        exitCode: exit_codes.USAGE);
+
+    await d.appDir({}).validate();
+    await d.dir(appPath, [
+      d.nothing('.dart_tool/package_config.json'),
+      d.nothing('pubspec.lock'),
+      d.nothing('.packages'),
+    ]).validate();
+  });
+
+  test('can be overriden by dependency override', () async {
+    await servePackages((builder) {
+      builder.serve('foo', '1.2.2');
+    });
+
+    await d.git(
+        'foo.git', [d.libDir('foo'), d.libPubspec('foo', '1.0.0')]).create();
+
+    await d.dir(appPath, [
+      d.pubspec({
+        'name': 'myapp',
+        'dependencies': {},
+        'dependency_overrides': {'foo': '1.2.2'}
+      })
+    ]).create();
+
+    await pubAdd(args: ['foo', '--git-url', '../foo.git']);
+
+    await d.cacheDir({'foo': '1.2.2'}).validate();
+    await d.appPackagesFile({'foo': '1.2.2'}).validate();
+    await d.dir(appPath, [
+      d.pubspec({
+        'name': 'myapp',
+        'dependencies': {
+          'foo': {'git': '../foo.git'}
+        },
+        'dependency_overrides': {'foo': '1.2.2'}
+      })
+    ]).validate();
+  });
+}
diff --git a/test/add/git/ref_test.dart b/test/add/git/ref_test.dart
new file mode 100644
index 0000000..11dfc0b
--- /dev/null
+++ b/test/add/git/ref_test.dart
@@ -0,0 +1,69 @@
+// Copyright (c) 2020, 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 'package:pub/src/exit_codes.dart' as exit_codes;
+import 'package:test/test.dart';
+
+import '../../descriptor.dart' as d;
+import '../../test_pub.dart';
+
+void main() {
+  test('adds a package from git with ref', () async {
+    ensureGit();
+
+    final repo = d.git(
+        'foo.git', [d.libDir('foo', 'foo 1'), d.libPubspec('foo', '1.0.0')]);
+    await repo.create();
+    await repo.runGit(['branch', 'old']);
+
+    await d.git('foo.git',
+        [d.libDir('foo', 'foo 2'), d.libPubspec('foo', '1.0.0')]).commit();
+
+    await d.appDir({}).create();
+
+    await pubAdd(args: ['foo', '--git-url', '../foo.git', '--git-ref', 'old']);
+
+    await d.dir(cachePath, [
+      d.dir('git', [
+        d.dir('cache', [
+          d.gitPackageRepoCacheDir('foo'),
+        ]),
+        d.gitPackageRevisionCacheDir('foo', modifier: 1),
+      ])
+    ]).validate();
+
+    await d.appDir({
+      'foo': {
+        'git': {'url': '../foo.git', 'ref': 'old'}
+      }
+    }).validate();
+  });
+
+  test('fails when adding from an invalid ref', () async {
+    ensureGit();
+
+    final repo = d.git(
+        'foo.git', [d.libDir('foo', 'foo 1'), d.libPubspec('foo', '1.0.0')]);
+    await repo.create();
+    await repo.runGit(['branch', 'new']);
+
+    await d.git('foo.git',
+        [d.libDir('foo', 'foo 2'), d.libPubspec('foo', '1.0.0')]).commit();
+
+    await d.appDir({}).create();
+
+    await pubAdd(
+        args: ['foo', '--git-url', '../foo.git', '--git-ref', 'old'],
+        error: contains('Unable to resolve package "foo" with the given '
+            'git parameters'),
+        exitCode: exit_codes.DATA);
+
+    await d.appDir({}).validate();
+    await d.dir(appPath, [
+      d.nothing('.dart_tool/package_config.json'),
+      d.nothing('pubspec.lock'),
+      d.nothing('.packages'),
+    ]).validate();
+  });
+}
diff --git a/test/add/git/subdir_test.dart b/test/add/git/subdir_test.dart
new file mode 100644
index 0000000..0f78069
--- /dev/null
+++ b/test/add/git/subdir_test.dart
@@ -0,0 +1,81 @@
+// Copyright (c) 2020, 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 'package:test/test.dart';
+
+import '../../descriptor.dart' as d;
+import '../../test_pub.dart';
+
+void main() {
+  test('adds a package from git subdirectory', () async {
+    ensureGit();
+
+    final repo = d.git('foo.git', [
+      d.dir('subdir', [d.libPubspec('sub', '1.0.0'), d.libDir('sub', '1.0.0')])
+    ]);
+
+    await repo.create();
+
+    await d.appDir({}).create();
+
+    await pubAdd(
+        args: ['sub', '--git-url', '../foo.git', '--git-path', 'subdir']);
+
+    await d.dir(cachePath, [
+      d.dir('git', [
+        d.dir('cache', [d.gitPackageRepoCacheDir('foo')]),
+        d.hashDir('foo', [
+          d.dir('subdir', [d.libDir('sub', '1.0.0')])
+        ])
+      ])
+    ]).validate();
+
+    await d.appPackagesFile({
+      'sub': pathInCache('git/foo-${await repo.revParse('HEAD')}/subdir')
+    }).validate();
+
+    await d.appDir({
+      'sub': {
+        'git': {'url': '../foo.git', 'path': 'subdir'}
+      }
+    }).validate();
+  });
+
+  test('adds a package in a deep subdirectory', () async {
+    ensureGit();
+
+    final repo = d.git('foo.git', [
+      d.dir('sub', [
+        d.dir('dir', [d.libPubspec('sub', '1.0.0'), d.libDir('sub', '1.0.0')])
+      ])
+    ]);
+    await repo.create();
+
+    await d.appDir({}).create();
+
+    await pubAdd(
+        args: ['sub', '--git-url', '../foo.git', '--git-path', 'sub/dir']);
+
+    await d.dir(cachePath, [
+      d.dir('git', [
+        d.dir('cache', [d.gitPackageRepoCacheDir('foo')]),
+        d.hashDir('foo', [
+          d.dir('sub', [
+            d.dir('dir', [d.libDir('sub', '1.0.0')])
+          ])
+        ])
+      ])
+    ]).validate();
+
+    await d.appPackagesFile({
+      'sub': pathInCache('git/foo-${await repo.revParse('HEAD')}/sub/dir')
+    }).validate();
+
+    await d.appDir({
+      'sub': {
+        'git': {'url': '../foo.git', 'path': 'sub/dir'}
+      }
+    }).validate();
+  });
+}
diff --git a/test/add/hosted/non_default_pub_server_test.dart b/test/add/hosted/non_default_pub_server_test.dart
new file mode 100644
index 0000000..75a4efd
--- /dev/null
+++ b/test/add/hosted/non_default_pub_server_test.dart
@@ -0,0 +1,142 @@
+// Copyright (c) 2020, 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 'package:pub/src/exit_codes.dart' as exit_codes;
+import 'package:test/test.dart';
+
+import '../../descriptor.dart' as d;
+import '../../test_pub.dart';
+
+void main() {
+  test('adds a package from a non-default pub server', () async {
+    // Make the default server serve errors. Only the custom server should
+    // be accessed.
+    await serveErrors();
+
+    var server = await PackageServer.start((builder) {
+      builder.serve('foo', '0.2.5');
+      builder.serve('foo', '1.1.0');
+      builder.serve('foo', '1.2.3');
+    });
+
+    await d.appDir({}).create();
+
+    final url = server.url;
+
+    await pubAdd(args: ['foo:1.2.3', '--hosted-url', url]);
+
+    await d.cacheDir({'foo': '1.2.3'}, port: server.port).validate();
+    await d.appPackagesFile({'foo': '1.2.3'}).validate();
+    await d.appDir({
+      'foo': {
+        'version': '1.2.3',
+        'hosted': {'name': 'foo', 'url': url}
+      }
+    }).validate();
+  });
+
+  test('fails when adding from an invalid url', () async {
+    ensureGit();
+
+    await d.appDir({}).create();
+
+    await pubAdd(
+        args: ['foo', '--hosted-url', 'https://invalid-url.foo'],
+        error: contains('Could not resolve URL "https://invalid-url.foo".'),
+        exitCode: exit_codes.DATA);
+
+    await d.appDir({}).validate();
+    await d.dir(appPath, [
+      d.nothing('.dart_tool/package_config.json'),
+      d.nothing('pubspec.lock'),
+      d.nothing('.packages'),
+    ]).validate();
+  });
+
+  test(
+      'adds a package from a non-default pub server with no version constraint',
+      () async {
+    // Make the default server serve errors. Only the custom server should
+    // be accessed.
+    await serveErrors();
+
+    var server = await PackageServer.start((builder) {
+      builder.serve('foo', '0.2.5');
+      builder.serve('foo', '1.1.0');
+      builder.serve('foo', '1.2.3');
+    });
+
+    await d.appDir({}).create();
+
+    final url = server.url;
+
+    await pubAdd(args: ['foo', '--hosted-url', url]);
+
+    await d.cacheDir({'foo': '1.2.3'}, port: server.port).validate();
+    await d.appPackagesFile({'foo': '1.2.3'}).validate();
+    await d.appDir({
+      'foo': {
+        'version': '^1.2.3',
+        'hosted': {'name': 'foo', 'url': url}
+      }
+    }).validate();
+  });
+
+  test('adds a package from a non-default pub server with a version constraint',
+      () async {
+    // Make the default server serve errors. Only the custom server should
+    // be accessed.
+    await serveErrors();
+
+    var server = await PackageServer.start((builder) {
+      builder.serve('foo', '0.2.5');
+      builder.serve('foo', '1.1.0');
+      builder.serve('foo', '1.2.3');
+    });
+
+    await d.appDir({}).create();
+
+    final url = server.url;
+
+    await pubAdd(args: ['foo', '--hosted-url', url]);
+
+    await d.cacheDir({'foo': '1.2.3'}, port: server.port).validate();
+    await d.appPackagesFile({'foo': '1.2.3'}).validate();
+    await d.appDir({
+      'foo': {
+        'version': '^1.2.3',
+        'hosted': {'name': 'foo', 'url': url}
+      }
+    }).validate();
+  });
+
+  test(
+      'adds a package from a non-default pub server with the "any" version '
+      'constraint', () async {
+    // Make the default server serve errors. Only the custom server should
+    // be accessed.
+    await serveErrors();
+
+    var server = await PackageServer.start((builder) {
+      builder.serve('foo', '0.2.5');
+      builder.serve('foo', '1.1.0');
+      builder.serve('foo', '1.2.3');
+    });
+
+    await d.appDir({}).create();
+
+    final url = server.url;
+
+    await pubAdd(args: ['foo:any', '--hosted-url', url]);
+
+    await d.cacheDir({'foo': '1.2.3'}, port: server.port).validate();
+    await d.appPackagesFile({'foo': '1.2.3'}).validate();
+    await d.appDir({
+      'foo': {
+        'version': 'any',
+        'hosted': {'name': 'foo', 'url': url}
+      }
+    }).validate();
+  });
+}
diff --git a/test/add/path/absolute_path_test.dart b/test/add/path/absolute_path_test.dart
new file mode 100644
index 0000000..0811bea
--- /dev/null
+++ b/test/add/path/absolute_path_test.dart
@@ -0,0 +1,120 @@
+// Copyright (c) 2020, 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 'package:path/path.dart' as path;
+import 'package:pub/src/exit_codes.dart' as exit_codes;
+import 'package:test/test.dart';
+
+import '../../descriptor.dart' as d;
+import '../../test_pub.dart';
+
+void main() {
+  test('path dependency with absolute path', () async {
+    await d
+        .dir('foo', [d.libDir('foo'), d.libPubspec('foo', '0.0.1')]).create();
+
+    await d.appDir({}).create();
+
+    final absolutePath = path.join(d.sandbox, 'foo');
+
+    await pubAdd(args: ['foo', '--path', absolutePath]);
+
+    await d.appPackagesFile({'foo': absolutePath}).validate();
+
+    await d.appDir({
+      'foo': {'path': absolutePath}
+    }).validate();
+  });
+
+  test('adds a package from absolute path with version constraint', () async {
+    await d
+        .dir('foo', [d.libDir('foo'), d.libPubspec('foo', '0.0.1')]).create();
+
+    await d.appDir({}).create();
+    final absolutePath = path.join(d.sandbox, 'foo');
+
+    await pubAdd(args: ['foo:0.0.1', '--path', absolutePath]);
+
+    await d.appDir({
+      'foo': {'path': absolutePath, 'version': '0.0.1'}
+    }).validate();
+  });
+
+  test('fails when adding with an invalid version constraint', () async {
+    ensureGit();
+
+    await d.git(
+        'foo.git', [d.libDir('foo'), d.libPubspec('foo', '1.0.0')]).create();
+
+    await d.appDir({}).create();
+    final absolutePath = path.join(d.sandbox, 'foo');
+
+    await pubAdd(
+        args: ['foo:2.0.0', '--path', absolutePath],
+        error: equalsIgnoringWhitespace(
+            'Because myapp depends on foo from path which doesn\'t exist '
+            '(could not find package foo at "$absolutePath"), version solving '
+            'failed.'),
+        exitCode: exit_codes.DATA);
+
+    await d.appDir({}).validate();
+    await d.dir(appPath, [
+      d.nothing('.dart_tool/package_config.json'),
+      d.nothing('pubspec.lock'),
+      d.nothing('.packages'),
+    ]).validate();
+  });
+
+  test('fails if path does not exist', () async {
+    await d.appDir({}).create();
+
+    final absolutePath = path.join(d.sandbox, 'foo');
+
+    await pubAdd(
+        args: ['foo', '--path', absolutePath],
+        error: equalsIgnoringWhitespace(
+            'Because myapp depends on foo from path which doesn\'t exist '
+            '(could not find package foo at "$absolutePath"), version solving '
+            'failed.'),
+        exitCode: exit_codes.DATA);
+
+    await d.appDir({}).validate();
+    await d.dir(appPath, [
+      d.nothing('.dart_tool/package_config.json'),
+      d.nothing('pubspec.lock'),
+      d.nothing('.packages'),
+    ]).validate();
+  });
+
+  test('can be overriden by dependency override', () async {
+    await servePackages((builder) {
+      builder.serve('foo', '1.2.2');
+    });
+    await d
+        .dir('foo', [d.libDir('foo'), d.libPubspec('foo', '0.0.1')]).create();
+
+    await d.dir(appPath, [
+      d.pubspec({
+        'name': 'myapp',
+        'dependencies': {},
+        'dependency_overrides': {'foo': '1.2.2'}
+      })
+    ]).create();
+
+    final absolutePath = path.join(d.sandbox, 'foo');
+    await pubAdd(args: ['foo', '--path', absolutePath]);
+
+    await d.cacheDir({'foo': '1.2.2'}).validate();
+    await d.appPackagesFile({'foo': '1.2.2'}).validate();
+    await d.dir(appPath, [
+      d.pubspec({
+        'name': 'myapp',
+        'dependencies': {
+          'foo': {'path': absolutePath}
+        },
+        'dependency_overrides': {'foo': '1.2.2'}
+      })
+    ]).validate();
+  });
+}
diff --git a/test/add/path/relative_path_test.dart b/test/add/path/relative_path_test.dart
new file mode 100644
index 0000000..6a191a6
--- /dev/null
+++ b/test/add/path/relative_path_test.dart
@@ -0,0 +1,114 @@
+// Copyright (c) 2020, 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' show Platform;
+
+import 'package:pub/src/exit_codes.dart' as exit_codes;
+import 'package:test/test.dart';
+
+import '../../descriptor.dart' as d;
+import '../../test_pub.dart';
+
+void main() {
+  test('can use relative path', () async {
+    await d
+        .dir('foo', [d.libDir('foo'), d.libPubspec('foo', '0.0.1')]).create();
+
+    await d.appDir({}).create();
+
+    await pubAdd(args: ['foo', '--path', '../foo']);
+
+    await d.appPackagesFile({'foo': '../foo'}).validate();
+
+    await d.appDir({
+      'foo': {'path': '../foo'}
+    }).validate();
+  });
+
+  test('fails if path does not exist', () async {
+    await d.appDir({}).create();
+
+    await pubAdd(
+        args: ['foo', '--path', '../foo'],
+        error: equalsIgnoringWhitespace(
+            'Because myapp depends on foo from path which doesn\'t exist '
+            '(could not find package foo at "..${Platform.pathSeparator}foo"), '
+            'version solving failed.'),
+        exitCode: exit_codes.DATA);
+
+    await d.appDir({}).validate();
+    await d.dir(appPath, [
+      d.nothing('.dart_tool/package_config.json'),
+      d.nothing('pubspec.lock'),
+      d.nothing('.packages'),
+    ]).validate();
+  });
+
+  test('adds a package from absolute path with version constraint', () async {
+    await d
+        .dir('foo', [d.libDir('foo'), d.libPubspec('foo', '0.0.1')]).create();
+
+    await d.appDir({}).create();
+
+    await pubAdd(args: ['foo:0.0.1', '--path', '../foo']);
+
+    await d.appDir({
+      'foo': {'path': '../foo', 'version': '0.0.1'}
+    }).validate();
+  });
+
+  test('fails when adding with an invalid version constraint', () async {
+    ensureGit();
+
+    await d.git(
+        'foo.git', [d.libDir('foo'), d.libPubspec('foo', '1.0.0')]).create();
+
+    await d.appDir({}).create();
+
+    await pubAdd(
+        args: ['foo:2.0.0', '--path', '../foo'],
+        error: equalsIgnoringWhitespace(
+            'Because myapp depends on foo from path which doesn\'t exist '
+            '(could not find package foo at "..${Platform.pathSeparator}foo"), '
+            'version solving failed.'),
+        exitCode: exit_codes.DATA);
+
+    await d.appDir({}).validate();
+    await d.dir(appPath, [
+      d.nothing('.dart_tool/package_config.json'),
+      d.nothing('pubspec.lock'),
+      d.nothing('.packages'),
+    ]).validate();
+  });
+
+  test('can be overriden by dependency override', () async {
+    await servePackages((builder) {
+      builder.serve('foo', '1.2.2');
+    });
+    await d
+        .dir('foo', [d.libDir('foo'), d.libPubspec('foo', '0.0.1')]).create();
+
+    await d.dir(appPath, [
+      d.pubspec({
+        'name': 'myapp',
+        'dependencies': {},
+        'dependency_overrides': {'foo': '1.2.2'}
+      })
+    ]).create();
+
+    await pubAdd(args: ['foo', '--path', '../foo']);
+
+    await d.cacheDir({'foo': '1.2.2'}).validate();
+    await d.appPackagesFile({'foo': '1.2.2'}).validate();
+    await d.dir(appPath, [
+      d.pubspec({
+        'name': 'myapp',
+        'dependencies': {
+          'foo': {'path': '../foo'}
+        },
+        'dependency_overrides': {'foo': '1.2.2'}
+      })
+    ]).validate();
+  });
+}
diff --git a/test/add/sdk/sdk_test.dart b/test/add/sdk/sdk_test.dart
new file mode 100644
index 0000000..25995e3
--- /dev/null
+++ b/test/add/sdk/sdk_test.dart
@@ -0,0 +1,109 @@
+// Copyright (c) 2020, 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 'package:path/path.dart' as p;
+import 'package:pub/src/exit_codes.dart' as exit_codes;
+import 'package:test/test.dart';
+
+import '../../descriptor.dart' as d;
+import '../../test_pub.dart';
+
+void main() {
+  setUp(() async {
+    await servePackages((builder) {
+      builder.serve('bar', '1.0.0');
+    });
+
+    await d.dir('flutter', [
+      d.dir('packages', [
+        d.dir('foo', [
+          d.libDir('foo', 'foo 0.0.1'),
+          d.libPubspec('foo', '0.0.1', deps: {'bar': 'any'})
+        ])
+      ]),
+      d.dir('bin/cache/pkg', [
+        d.dir(
+            'baz', [d.libDir('baz', 'foo 0.0.1'), d.libPubspec('baz', '0.0.1')])
+      ]),
+      d.file('version', '1.2.3')
+    ]).create();
+  });
+
+  test("adds an SDK dependency's dependencies", () async {
+    await d.appDir({}).create();
+    await pubAdd(
+        args: ['foo', '--sdk', 'flutter'],
+        environment: {'FLUTTER_ROOT': p.join(d.sandbox, 'flutter')});
+
+    await d.dir(appPath, [
+      d.pubspec({
+        'name': 'myapp',
+        'dependencies': {
+          'foo': {'sdk': 'flutter', 'version': '^0.0.1'}
+        }
+      }),
+      d.packagesFile({
+        'myapp': '.',
+        'foo': p.join(d.sandbox, 'flutter', 'packages', 'foo'),
+        'bar': '1.0.0'
+      })
+    ]).validate();
+  });
+
+  test(
+      "adds an SDK dependency's dependencies with version constraint specified",
+      () async {
+    await d.appDir({}).create();
+    await pubAdd(
+        args: ['foo:0.0.1', '--sdk', 'flutter'],
+        environment: {'FLUTTER_ROOT': p.join(d.sandbox, 'flutter')});
+
+    await d.dir(appPath, [
+      d.pubspec({
+        'name': 'myapp',
+        'dependencies': {
+          'foo': {'sdk': 'flutter', 'version': '0.0.1'}
+        }
+      }),
+      d.packagesFile({
+        'myapp': '.',
+        'foo': p.join(d.sandbox, 'flutter', 'packages', 'foo'),
+        'bar': '1.0.0'
+      })
+    ]).validate();
+  });
+
+  test('adds an SDK dependency from bin/cache/pkg', () async {
+    await d.appDir({}).create();
+    await pubAdd(
+        args: ['baz', '--sdk', 'flutter'],
+        environment: {'FLUTTER_ROOT': p.join(d.sandbox, 'flutter')});
+
+    await d.dir(appPath, [
+      d.packagesFile({
+        'myapp': '.',
+        'baz': p.join(d.sandbox, 'flutter', 'bin', 'cache', 'pkg', 'baz')
+      })
+    ]).validate();
+  });
+
+  test("fails if the version constraint doesn't match", () async {
+    await d.appDir({}).create();
+    await pubAdd(
+        args: ['foo:^1.0.0', '--sdk', 'flutter'],
+        environment: {'FLUTTER_ROOT': p.join(d.sandbox, 'flutter')},
+        error: equalsIgnoringWhitespace("""
+              Because myapp depends on foo ^1.0.0 from sdk which doesn't match
+                any versions, version solving failed.
+            """),
+        exitCode: exit_codes.DATA);
+
+    await d.appDir({}).validate();
+    await d.dir(appPath, [
+      d.nothing('.dart_tool/package_config.json'),
+      d.nothing('pubspec.lock'),
+      d.nothing('.packages'),
+    ]).validate();
+  });
+}
diff --git a/test/descriptor.dart b/test/descriptor.dart
index 7dcfc6f..73518ee 100644
--- a/test/descriptor.dart
+++ b/test/descriptor.dart
@@ -13,6 +13,7 @@
 import 'descriptor/git.dart';
 import 'descriptor/packages.dart';
 import 'descriptor/tar.dart';
+import 'descriptor/yaml.dart';
 import 'test_pub.dart';
 
 export 'package:test_descriptor/test_descriptor.dart';
@@ -49,7 +50,7 @@
 /// [contents] may contain [Future]s that resolve to serializable objects,
 /// which may in turn contain [Future]s recursively.
 Descriptor pubspec(Map<String, Object> contents) =>
-    file('pubspec.yaml', yaml(contents));
+    YamlDescriptor('pubspec.yaml', yaml(contents));
 
 /// Describes a file named `pubspec.yaml` for an application package with the
 /// given [dependencies].
diff --git a/test/descriptor/yaml.dart b/test/descriptor/yaml.dart
new file mode 100644
index 0000000..fc56ee3
--- /dev/null
+++ b/test/descriptor/yaml.dart
@@ -0,0 +1,48 @@
+// Copyright (c) 2020, 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:async' show Future;
+import 'dart:convert' show utf8;
+import 'dart:io';
+
+import 'package:collection/collection.dart';
+import 'package:path/path.dart' as p;
+import 'package:test/test.dart';
+import 'package:test_descriptor/test_descriptor.dart';
+import 'package:yaml/yaml.dart';
+
+import '../descriptor.dart';
+
+/// Describes a YAML file and its contents.
+class YamlDescriptor extends FileDescriptor {
+  /// Contents of the YAML file. Must be valid YAML.
+  final String _contents;
+
+  YamlDescriptor(String name, this._contents) : super.protected(name);
+
+  @override
+  Future<String> read() async => _contents;
+
+  @override
+  Stream<List<int>> readAsBytes() =>
+      Stream.fromIterable([utf8.encode(_contents)]);
+
+  @override
+  Future validate([String parent]) async {
+    var fullPath = p.join(parent ?? sandbox, name);
+    if (!await File(fullPath).exists()) {
+      fail("File not found: '$fullPath'.");
+    }
+
+    var bytes = await File(fullPath).readAsBytes();
+
+    final actualContentsText = utf8.decode(bytes);
+    final actual = loadYaml(actualContentsText);
+    final expected = loadYaml(_contents);
+
+    if (!DeepCollectionEquality().equals(expected, actual)) {
+      fail('Expected $expected, found: $actual');
+    }
+  }
+}
diff --git a/test/pub_test.dart b/test/pub_test.dart
index 21227d2..0d0c91b 100644
--- a/test/pub_test.dart
+++ b/test/pub_test.dart
@@ -29,6 +29,7 @@
         -v, --verbose          Shortcut for "--verbosity=all".
 
         Available commands:
+          add         Add a dependency to pubspec.yaml.
           cache       Work with the system cache.
           deps        Print package dependencies.
           downgrade   Downgrade the current package's dependencies to oldest versions.
diff --git a/test/test_pub.dart b/test/test_pub.dart
index 3dd5407..8c5cc73 100644
--- a/test/test_pub.dart
+++ b/test/test_pub.dart
@@ -83,6 +83,8 @@
 /// Enum identifying a pub command that can be run with a well-defined success
 /// output.
 class RunCommand {
+  static final add = RunCommand(
+      'add', RegExp(r'Got dependencies!|Changed \d+ dependenc(y|ies)!'));
   static final get = RunCommand(
       'get', RegExp(r'Got dependencies!|Changed \d+ dependenc(y|ies)!'));
   static final upgrade = RunCommand('upgrade', RegExp(r'''
@@ -152,6 +154,21 @@
       environment: environment);
 }
 
+Future pubAdd(
+        {Iterable<String> args,
+        output,
+        error,
+        warning,
+        int exitCode,
+        Map<String, String> environment}) =>
+    pubCommand(RunCommand.add,
+        args: args,
+        output: output,
+        error: error,
+        warning: warning,
+        exitCode: exitCode,
+        environment: environment);
+
 Future pubGet(
         {Iterable<String> args,
         output,