Handle relative git-url-paths correctly when --directory (#2919)

diff --git a/lib/src/command/add.dart b/lib/src/command/add.dart
index d74dd6c..7d7d19e 100644
--- a/lib/src/command/add.dart
+++ b/lib/src/command/add.dart
@@ -2,6 +2,8 @@
 // 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/source/path.dart';
 import 'package:pub_semver/pub_semver.dart';
 import 'package:yaml/yaml.dart';
 
@@ -313,25 +315,45 @@
       if (gitUrl == null) {
         usageException('The `--git-url` is required for git dependencies.');
       }
+      Uri parsed;
+      try {
+        parsed = Uri.parse(gitUrl);
+      } on FormatException catch (e) {
+        usageException('The --git-url must be a valid url: ${e.message}.');
+      }
+      final urlRelativeToEntrypoint = parsed.isAbsolute
+          ? parsed.toString()
+          :
+          // Turn the relative url from current working directory into a relative
+          // url from the entrypoint.
+          p.url.relative(
+              p.url.join(Uri.file(p.absolute(p.current)).toString(),
+                  parsed.toString()),
+              from: p.toUri(p.absolute(entrypoint.root.dir)).toString());
 
       /// Process the git options to return the simplest representation to be
       /// added to the pubspec.
       if (gitRef == null && gitPath == null) {
-        git = gitUrl;
+        git = urlRelativeToEntrypoint;
       } else {
-        git = {'url': gitUrl, 'ref': gitRef, 'path': gitPath};
+        git = {'url': urlRelativeToEntrypoint, 'ref': gitRef, 'path': gitPath};
         git.removeWhere((key, value) => value == null);
       }
 
       packageRange = cache.sources['git']
-          .parseRef(packageName, git)
+          .parseRef(packageName, git, containingPath: entrypoint.pubspecPath)
           .withConstraint(constraint ?? VersionConstraint.any);
       pubspecInformation = {'git': git};
     } else if (path != null) {
+      final relativeToEntryPoint = p.isRelative(path)
+          ? PathSource.relativePathWithPosixSeparators(
+              p.relative(path, from: entrypoint.root.dir))
+          : path;
       packageRange = cache.sources['path']
-          .parseRef(packageName, path, containingPath: entrypoint.pubspecPath)
+          .parseRef(packageName, relativeToEntryPoint,
+              containingPath: entrypoint.pubspecPath)
           .withConstraint(constraint ?? VersionConstraint.any);
-      pubspecInformation = {'path': path};
+      pubspecInformation = {'path': relativeToEntryPoint};
     } else if (sdk != null) {
       packageRange = cache.sources['sdk']
           .parseRef(packageName, sdk)
diff --git a/lib/src/global_packages.dart b/lib/src/global_packages.dart
index 726a1a4..595c604 100644
--- a/lib/src/global_packages.dart
+++ b/lib/src/global_packages.dart
@@ -90,9 +90,14 @@
     // dependencies. Their executables shouldn't be cached, and there should
     // be a mechanism for redoing dependency resolution if a path pubspec has
     // changed (see also issue 20499).
+    PackageRef ref;
+    try {
+      ref = cache.git.source.parseRef(name, {'url': repo}, containingPath: '.');
+    } on FormatException catch (e) {
+      throw ApplicationException(e.message);
+    }
     await _installInCache(
-        cache.git.source
-            .refFor(name, repo)
+        ref
             .withConstraint(VersionConstraint.any)
             .withFeatures(features ?? const {}),
         executables,
@@ -282,7 +287,8 @@
     var oldPath = p.join(_directory, '$package.lock');
     if (fileExists(oldPath)) deleteEntry(oldPath);
 
-    writeTextFile(_getLockFilePath(package), lockFile.serialize(cache.rootDir));
+    writeTextFile(_getLockFilePath(package),
+        lockFile.serialize(p.join(_directory, package)));
   }
 
   /// Shows the user the currently active package with [name], if any.
diff --git a/lib/src/source/git.dart b/lib/src/source/git.dart
index c986015..adcfee7 100644
--- a/lib/src/source/git.dart
+++ b/lib/src/source/git.dart
@@ -29,61 +29,44 @@
   BoundGitSource bind(SystemCache systemCache) =>
       BoundGitSource(this, systemCache);
 
-  /// Returns a reference to a git package with the given [name] and [url].
-  ///
-  /// If passed, [reference] is the Git reference. It defaults to `"HEAD"`.
-  PackageRef refFor(String name, String url, {String reference, String path}) {
-    if (path != null) assert(p.url.isRelative(path));
-    return PackageRef(name, this,
-        {'url': url, 'ref': reference ?? 'HEAD', 'path': path ?? '.'});
-  }
-
   /// Given a valid git package description, returns the URL of the repository
   /// it pulls from.
-  String urlFromDescription(description) => description['url'];
+  /// If the url is relative, it will be returned relative to current working
+  /// directory.
+  String urlFromDescription(description) {
+    var url = description['url'];
+    if (description['relative'] == true) {
+      return p.url.relative(url, from: p.toUri(p.current).toString());
+    }
+    return url;
+  }
 
   @override
   PackageRef parseRef(String name, description, {String containingPath}) {
-    // TODO(rnystrom): Handle git URLs that are relative file paths (#8570).
-    if (description is String) description = {'url': description};
-
-    if (description is! Map) {
+    dynamic url;
+    dynamic ref;
+    dynamic path;
+    if (description is String) {
+      url = description;
+    } else if (description is! Map) {
       throw FormatException('The description must be a Git URL or a map '
           "with a 'url' key.");
-    }
+    } else {
+      url = description['url'];
 
-    if (description['url'] is! String) {
-      throw FormatException("The 'url' field of the description must be a "
-          'string.');
-    }
-
-    _validateUrl(description['url']);
-
-    var ref = description['ref'];
-    if (ref != null && ref is! String) {
-      throw FormatException("The 'ref' field of the description must be a "
-          'string.');
-    }
-
-    var path = description['path'];
-    if (path != null) {
-      if (path is! String) {
-        throw FormatException(
-            "The 'path' field of the description must be a string.");
-      } else if (!p.url.isRelative(path)) {
-        throw FormatException(
-            "The 'path' field of the description must be relative.");
-      } else if (!p.url.isWithin('.', path) && !p.url.equals('.', path)) {
-        throw FormatException(
-            "The 'path' field of the description must not reach outside the "
-            'repository.');
+      ref = description['ref'];
+      if (ref != null && ref is! String) {
+        throw FormatException("The 'ref' field of the description must be a "
+            'string.');
       }
 
-      _validateUrl(path);
+      path = description['path'];
     }
-
-    return PackageRef(name, this,
-        {'url': description['url'], 'ref': ref ?? 'HEAD', 'path': path ?? '.'});
+    return PackageRef(name, this, {
+      ..._validatedUrl(url, containingPath),
+      'ref': ref ?? 'HEAD',
+      'path': _validatedPath(path),
+    });
   }
 
   @override
@@ -94,13 +77,6 @@
           'key.');
     }
 
-    if (description['url'] is! String) {
-      throw FormatException("The 'url' field of the description must be a "
-          'string.');
-    }
-
-    _validateUrl(description['url']);
-
     var ref = description['ref'];
     if (ref != null && ref is! String) {
       throw FormatException("The 'ref' field of the description must be a "
@@ -112,35 +88,78 @@
           'must be a string.');
     }
 
-    var path = description['path'];
-    if (path != null) {
-      if (path is! String) {
-        throw FormatException(
-            "The 'path' field of the description must be a string.");
-      } else if (!p.url.isRelative(path)) {
-        throw FormatException(
-            "The 'path' field of the description must be relative.");
-      }
-
-      _validateUrl(path);
-    }
-
     return PackageId(name, this, version, {
-      'url': description['url'],
+      ..._validatedUrl(description['url'], containingPath),
       'ref': ref ?? 'HEAD',
       'resolved-ref': description['resolved-ref'],
-      'path': path ?? '.'
+      'path': _validatedPath(description['path'])
     });
   }
 
+  /// Serializes path dependency's [description].
+  ///
+  /// For the descriptions where `relative` attribute is `true`, tries to make
+  /// `url` relative to the specified [containingPath].
+  @override
+  dynamic serializeDescription(String containingPath, description) {
+    final copy = Map.from(description);
+    copy.remove('relative');
+    if (description['relative'] == true) {
+      copy['url'] = p.url.relative(description['url'],
+          from: Uri.file(containingPath).toString());
+    }
+    return copy;
+  }
+
   /// Throws a [FormatException] if [url] isn't a valid Git URL.
-  void _validateUrl(String url) {
+  Map<String, Object> _validatedUrl(dynamic url, String containingDir) {
+    if (url is! String) {
+      throw FormatException("The 'url' field of the description must be a "
+          'string.');
+    }
+    var relative = false;
     // If the URL contains an @, it's probably an SSH hostname, which we don't
     // know how to validate.
-    if (url.contains('@')) return;
+    if (!url.contains('@')) {
+      // Otherwise, we use Dart's URL parser to validate the URL.
+      final parsed = Uri.parse(url);
+      if (!parsed.hasAbsolutePath) {
+        // Relative paths coming from pubspecs that are not on the local file
+        // system aren't allowed. This can happen if a hosted or git dependency
+        // has a git dependency.
+        if (containingDir == null) {
+          throw FormatException('"$url" is a relative path, but this '
+              'isn\'t a local pubspec.');
+        }
+        // A relative path is stored internally as absolute resolved relative to
+        // [containingPath].
+        relative = true;
+        url = Uri.file(p.absolute(containingDir)).resolveUri(parsed).toString();
+      }
+    }
+    return {'relative': relative, 'url': url};
+  }
 
-    // Otherwise, we use Dart's URL parser to validate the URL.
-    Uri.parse(url);
+  /// Throws a [FormatException] if [path] isn't a relative url or null.
+  String _validatedPath(dynamic path) {
+    path ??= '.';
+    if (path is! String) {
+      throw FormatException("The 'path' field of the description must be a "
+          'string.');
+    }
+
+    // Use Dart's URL parser to validate the URL.
+    final parsed = Uri.parse(path);
+    if (parsed.isAbsolute) {
+      throw FormatException(
+          "The 'path' field of the description must be relative.");
+    }
+    if (!p.url.isWithin('.', path) && !p.url.equals('.', path)) {
+      throw FormatException(
+          "The 'path' field of the description must not reach outside the "
+          'repository.');
+    }
+    return p.normalize(parsed.toString());
   }
 
   /// If [description] has a resolved ref, print it out in short-form.
@@ -150,7 +169,7 @@
   @override
   String formatDescription(description) {
     if (description is Map && description.containsKey('resolved-ref')) {
-      var result = "${description['url']} at "
+      var result = '${urlFromDescription(description)} at '
           "${description['resolved-ref'].substring(0, 6)}";
       if (description['path'] != '.') result += " in ${description["path"]}";
       return result;
@@ -230,12 +249,17 @@
       await _ensureRepoCache(ref);
       var path = _repoCachePath(ref);
       var revision = await _firstRevision(path, ref.description['ref']);
-      var pubspec =
-          await _describeUncached(ref, revision, ref.description['path']);
+      var pubspec = await _describeUncached(
+        ref,
+        revision,
+        ref.description['path'],
+        source.urlFromDescription(ref.description),
+      );
 
       return [
         PackageId(ref.name, source, pubspec.version, {
           'url': ref.description['url'],
+          'relative': ref.description['relative'],
           'ref': ref.description['ref'],
           'resolved-ref': revision,
           'path': ref.description['path']
@@ -249,13 +273,21 @@
   @override
   Future<Pubspec> describeUncached(PackageId id) {
     return _pool.withResource(() => _describeUncached(
-        id.toRef(), id.description['resolved-ref'], id.description['path']));
+          id.toRef(),
+          id.description['resolved-ref'],
+          id.description['path'],
+          source.urlFromDescription(id.description),
+        ));
   }
 
   /// Like [describeUncached], but takes a separate [ref] and Git [revision]
   /// rather than a single ID.
   Future<Pubspec> _describeUncached(
-      PackageRef ref, String revision, String path) async {
+    PackageRef ref,
+    String revision,
+    String path,
+    String url,
+  ) async {
     await _ensureRevision(ref, revision);
     var repoPath = _repoCachePath(ref);
 
@@ -265,18 +297,20 @@
 
     // Git doesn't recognize backslashes in paths, even on Windows.
     if (Platform.isWindows) pubspecPath = pubspecPath.replaceAll('\\', '/');
-
     List<String> lines;
     try {
       lines = await git
           .run(['show', '$revision:$pubspecPath'], workingDir: repoPath);
     } on git.GitException catch (_) {
       fail('Could not find a file named "$pubspecPath" in '
-          '${ref.description['url']} $revision.');
+          '${source.urlFromDescription(ref.description)} $revision.');
     }
 
-    return Pubspec.parse(lines.join('\n'), systemCache.sources,
-        expectedName: ref.name);
+    return Pubspec.parse(
+      lines.join('\n'),
+      systemCache.sources,
+      expectedName: ref.name,
+    );
   }
 
   /// Clones a Git repo to the local filesystem.
@@ -443,7 +477,6 @@
   Future _createRepoCache(PackageRef ref) async {
     var path = _repoCachePath(ref);
     assert(!_updatedRepos.contains(path));
-
     try {
       await _clone(ref.description['url'], path, mirror: true);
     } catch (_) {
@@ -541,16 +574,23 @@
   ///
   /// If [shallow] is true, creates a shallow clone that contains no history
   /// for the repository.
-  Future _clone(String from, String to,
-      {bool mirror = false, bool shallow = false}) {
+  Future _clone(
+    String from,
+    String to, {
+    bool mirror = false,
+    bool shallow = false,
+  }) {
     return Future.sync(() {
       // Git on Windows does not seem to automatically create the destination
       // directory.
       ensureDir(to);
-      var args = ['clone', from, to];
-
-      if (mirror) args.insert(1, '--mirror');
-      if (shallow) args.insertAll(1, ['--depth', '1']);
+      var args = [
+        'clone',
+        if (mirror) '--mirror',
+        if (shallow) ...['--depth', '1'],
+        from,
+        to
+      ];
 
       return git.run(args);
     }).then((result) => null);
diff --git a/test/add/git/git_test.dart b/test/add/git/git_test.dart
index 305b6a6..2577af8 100644
--- a/test/add/git/git_test.dart
+++ b/test/add/git/git_test.dart
@@ -31,6 +31,48 @@
     }).validate();
   });
 
+  test('adds a package from git with relative url and --directory', () async {
+    ensureGit();
+
+    await d.git(
+        'foo.git', [d.libDir('foo'), d.libPubspec('foo', '1.0.0')]).create();
+
+    await d.appDir({}).create();
+
+    await pubAdd(
+      args: ['--directory', appPath, 'foo', '--git-url', 'foo.git'],
+      workingDirectory: d.sandbox,
+      output: contains('Changed 1 dependency in myapp!'),
+    );
+
+    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('fails with invalid --git-url', () 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', ':'],
+      error:
+          contains('The --git-url must be a valid url: Invalid empty scheme.'),
+      exitCode: exit_codes.USAGE,
+    );
+  });
+
   test('adds a package from git with version constraint', () async {
     ensureGit();
 
@@ -76,7 +118,7 @@
     ]).validate();
   });
 
-  test('fails when adding from an invalid url', () async {
+  test('fails when adding from an non-existing url', () async {
     ensureGit();
 
     await d.appDir({}).create();
diff --git a/test/add/path/relative_path_test.dart b/test/add/path/relative_path_test.dart
index 6a191a6..8fa1932 100644
--- a/test/add/path/relative_path_test.dart
+++ b/test/add/path/relative_path_test.dart
@@ -26,6 +26,25 @@
     }).validate();
   });
 
+  test('can use relative path with --directory', () async {
+    await d
+        .dir('foo', [d.libDir('foo'), d.libPubspec('foo', '0.0.1')]).create();
+
+    await d.appDir({}).create();
+
+    await pubAdd(
+      args: ['--directory', appPath, 'foo', '--path', 'foo'],
+      workingDirectory: d.sandbox,
+      output: contains('Changed 1 dependency in myapp!'),
+    );
+
+    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();
 
diff --git a/test/get/git/check_out_transitive_test.dart b/test/get/git/check_out_transitive_test.dart
index 1e897fb..537e670 100644
--- a/test/get/git/check_out_transitive_test.dart
+++ b/test/get/git/check_out_transitive_test.dart
@@ -2,7 +2,9 @@
 // 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:test/test.dart';
+import 'package:pub/src/exit_codes.dart' as exit_codes;
 
 import '../../descriptor.dart' as d;
 import '../../test_pub.dart';
@@ -14,7 +16,10 @@
     await d.git('foo.git', [
       d.libDir('foo'),
       d.libPubspec('foo', '1.0.0', deps: {
-        'bar': {'git': '../bar.git'}
+        'bar': {
+          'git':
+              p.toUri(p.absolute(d.sandbox, appPath, '../bar.git')).toString()
+        }
       })
     ]).create();
 
@@ -22,7 +27,9 @@
         'bar.git', [d.libDir('bar'), d.libPubspec('bar', '1.0.0')]).create();
 
     await d.appDir({
-      'foo': {'git': '../foo.git'}
+      'foo': {
+        'git': p.toUri(p.absolute(d.sandbox, appPath, '../foo.git')).toString()
+      }
     }).create();
 
     await pubGet();
@@ -39,4 +46,57 @@
     expect(packageSpec('foo'), isNotNull);
     expect(packageSpec('bar'), isNotNull);
   });
+
+  test('cannot have relative git url packages transitively from Git', () async {
+    ensureGit();
+
+    await d.git('foo.git', [
+      d.libDir('foo'),
+      d.libPubspec('foo', '1.0.0', deps: {
+        'bar': {'git': '../bar.git'}
+      })
+    ]).create();
+
+    await d.git(
+        'bar.git', [d.libDir('bar'), d.libPubspec('bar', '1.0.0')]).create();
+
+    await d.appDir({
+      'foo': {
+        'git': p.toUri(p.absolute(d.sandbox, appPath, '../foo.git')).toString()
+      }
+    }).create();
+
+    await pubGet(
+      error: contains(
+          '"../bar.git" is a relative path, but this isn\'t a local pubspec.'),
+      exitCode: exit_codes.DATA,
+    );
+  });
+
+  test('cannot have relative path dependencies transitively from Git',
+      () async {
+    ensureGit();
+
+    await d.git('foo.git', [
+      d.libDir('foo'),
+      d.libPubspec('foo', '1.0.0', deps: {
+        'bar': {'path': '../bar'}
+      })
+    ]).create();
+
+    await d
+        .dir('bar', [d.libDir('bar'), d.libPubspec('bar', '1.0.0')]).create();
+
+    await d.appDir({
+      'foo': {
+        'git': p.toUri(p.absolute(d.sandbox, appPath, '../foo.git')).toString()
+      }
+    }).create();
+
+    await pubGet(
+      error: contains(
+          '"../bar" is a relative path, but this isn\'t a local pubspec.'),
+      exitCode: exit_codes.DATA,
+    );
+  });
 }
diff --git a/test/upgrade/git/do_not_upgrade_if_unneeded_test.dart b/test/upgrade/git/do_not_upgrade_if_unneeded_test.dart
index 2b948b3..6e8c7df 100644
--- a/test/upgrade/git/do_not_upgrade_if_unneeded_test.dart
+++ b/test/upgrade/git/do_not_upgrade_if_unneeded_test.dart
@@ -3,6 +3,7 @@
 // BSD-style license that can be found in the LICENSE file.
 
 import 'package:test/test.dart';
+import 'package:path/path.dart' as p;
 
 import '../../descriptor.dart' as d;
 import '../../test_pub.dart';
@@ -16,7 +17,11 @@
     await d.git('foo.git', [
       d.libDir('foo'),
       d.libPubspec('foo', '1.0.0', deps: {
-        'foo_dep': {'git': '../foo_dep.git'}
+        'foo_dep': {
+          'git': p
+              .toUri(p.absolute(d.sandbox, appPath, '../foo_dep.git'))
+              .toString()
+        }
       })
     ]).create();
 
@@ -45,7 +50,11 @@
     await d.git('foo.git', [
       d.libDir('foo', 'foo 2'),
       d.libPubspec('foo', '1.0.0', deps: {
-        'foo_dep': {'git': '../foo_dep.git'}
+        'foo_dep': {
+          'git': p
+              .toUri(p.absolute(d.sandbox, appPath, '../foo_dep.git'))
+              .toString()
+        }
       })
     ]).create();
 
diff --git a/test/version_solver_test.dart b/test/version_solver_test.dart
index 7c1ad45..a7132e7 100644
--- a/test/version_solver_test.dart
+++ b/test/version_solver_test.dart
@@ -240,7 +240,7 @@
     await servePackages((builder) {
       builder.serve('foo', '1.0.0', deps: {'myapp': 'any'});
       builder.serve('bar', '1.0.0', deps: {
-        'myapp': {'git': 'nowhere'}
+        'myapp': {'git': 'http://nowhere.com/'}
       });
     });