Newer
Older
// Copyright (c) 2012, 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';
import 'package:path/path.dart' as p;
import 'package:pub_semver/pub_semver.dart';
import '../git.dart' as git;
import '../io.dart';
rnystrom@google.com
committed
import '../log.dart' as log;
import '../package_name.dart';
import '../source.dart';
import '../system_cache.dart';
/// A package source that gets packages from Git repos.
class GitSource extends Source {
final name = "git";
BoundGitSource bind(SystemCache systemCache) =>
new 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 new 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"];
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) {
throw new FormatException("The description must be a Git URL or a map "
"with a 'url' key.");
}
if (description["url"] is! String) {
throw new FormatException("The 'url' field of the description must be a "
"string.");
}
var ref = description["ref"];
if (ref != null && ref is! String) {
throw new FormatException("The 'ref' field of the description must be a "
"string.");
var path = description["path"];
if (path != null) {
if (path is! String) {
throw new FormatException(
"The 'path' field of the description must be a string.");
} else if (!p.url.isRelative(path)) {
throw new FormatException(
"The 'path' field of the description must be relative.");
} else if (!p.url.isWithin('.', path)) {
throw new FormatException(
"The 'path' field of the description must not reach outside the "
"repository.");
}
_validateUrl(path);
}
{"url": description["url"], "ref": ref ?? "HEAD", "path": path ?? "."});
}
PackageId parseId(String name, Version version, description) {
if (description is! Map) {
throw new FormatException("The description must be a map with a 'url' "
"key.");
}
if (description["url"] is! String) {
throw new FormatException("The 'url' field of the description must be a "
"string.");
}
var ref = description["ref"];
if (ref != null && ref is! String) {
throw new FormatException("The 'ref' field of the description must be a "
"string.");
}
if (description["resolved-ref"] is! String) {
throw new FormatException("The 'resolved-ref' field of the description "
"must be a string.");
}
var path = description["path"];
if (path != null) {
if (path is! String) {
throw new FormatException(
"The 'path' field of the description must be a string.");
} else if (!p.url.isRelative(path)) {
throw new FormatException(
"The 'path' field of the description must be relative.");
}
_validateUrl(path);
}
return new PackageId(name, this, version, {
"url": description["url"],
"ref": ref ?? "HEAD",
"resolved-ref": description["resolved-ref"],
"path": path ?? "."
/// Throws a [FormatException] if [url] isn't a valid Git URL.
void _validateUrl(String url) {
// If the URL contains an @, it's probably an SSH hostname, which we don't
// know how to validate.
if (url.contains("@")) return;
// Otherwise, we use Dart's URL parser to validate the URL.
Uri.parse(url);
}
/// If [description] has a resolved ref, print it out in short-form.
///
/// This helps distinguish different git commits with the same pubspec
/// version.
String formatDescription(String containingPath, description) {
if (description is Map && description.containsKey('resolved-ref')) {
var result = "${description['url']} at "
"${description['resolved-ref'].substring(0, 6)}";
if (description["path"] != ".") result += " in ${description["path"]}";
return result;
} else {
return super.formatDescription(containingPath, description);
}
}
/// Two Git descriptions are equal if both their URLs and their refs are
/// equal.
bool descriptionsEqual(description1, description2) {
// TODO(nweiz): Do we really want to throw an error if you have two
// dependencies on some repo, one of which specifies a ref and one of which
// doesn't? If not, how do we handle that case in the version solver?
if (description1['url'] != description2['url']) return false;
if (description1['ref'] != description2['ref']) return false;
if (description1['path'] != description2['path']) return false;
if (description1.containsKey('resolved-ref') &&
description2.containsKey('resolved-ref')) {
return description1['resolved-ref'] == description2['resolved-ref'];
}
return true;
int hashDescription(description) {
// Don't include the resolved ref in the hash code because we ignore it in
// [descriptionsEqual] if only one description defines it.
return description['url'].hashCode ^
description['ref'].hashCode ^
description['path'].hashCode;
}
/// The [BoundSource] for [GitSource].
class BoundGitSource extends CachedSource {
final GitSource source;
final SystemCache systemCache;
/// A map from revision cache locations to futures that will complete once
/// they're finished being cloned.
///
/// This lets us avoid race conditions when getting multiple different
/// packages from the same repository.
final _revisionCacheClones = <String, Future>{};
/// The paths to the canonical clones of repositories for which "git fetch"
/// has already been run during this run of pub.
final _updatedRepos = new Set<String>();
BoundGitSource(this.source, this.systemCache);
/// Given a Git repo that contains a pub package, gets the name of the pub
/// package.
Future<String> getPackageNameFromRepo(String repo) {
// Clone the repo to a temp directory.
return withTempDir((tempDir) async {
await _clone(repo, tempDir, shallow: true);
var pubspec = new Pubspec.load(tempDir, systemCache.sources);
return pubspec.name;
});
}
Future<List<PackageId>> doGetVersions(PackageRef ref) async {
await _ensureRepoCache(ref);
var path = _repoCachePath(ref);
var revision = await _firstRevision(path, ref.description['ref']);
var pubspec =
await _describeUncached(ref, revision, ref.description['path']);
new PackageId(ref.name, source, pubspec.version, {
'url': ref.description['url'],
'ref': ref.description['ref'],
'resolved-ref': revision,
'path': ref.description['path']
})
];
}
/// Since we don't have an easy way to read from a remote Git repo, this
/// just installs [id] into the system cache, then describes it from there.
Future<Pubspec> describeUncached(PackageId id) => _describeUncached(
id.toRef(), id.description['resolved-ref'], id.description['path']);
/// 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 {
await _ensureRevision(ref, revision);
var repoPath = _repoCachePath(ref);
// Normalize the path because Git treats "./" at the beginning of a path
// specially.
var pubspecPath = p.normalize(p.join(p.fromUri(path), 'pubspec.yaml'));
lines = await git
.run(["show", "$revision:$pubspecPath"], workingDir: repoPath);
} on git.GitException catch (_) {
fail('Could not find a file named "$pubspecPath" in '
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
'${ref.description['url']} $revision.');
}
return new Pubspec.parse(lines.join("\n"), systemCache.sources,
expectedName: ref.name);
}
/// Clones a Git repo to the local filesystem.
///
/// The Git cache directory is a little idiosyncratic. At the top level, it
/// contains a directory for each commit of each repository, named `<package
/// name>-<commit hash>`. These are the canonical package directories that are
/// linked to from the `packages/` directory.
///
/// In addition, the Git system cache contains a subdirectory named `cache/`
/// which contains a directory for each separate repository URL, named
/// `<package name>-<url hash>`. These are used to check out the repository
/// itself; each of the commit-specific directories are clones of a directory
/// in `cache/`.
Future<Package> downloadToSystemCache(PackageId id) async {
var ref = id.toRef();
if (!git.isInstalled) {
fail("Cannot get ${id.name} from Git (${ref.description['url']}).\n"
"Please ensure Git is correctly installed.");
}
ensureDir(p.join(systemCacheRoot, 'cache'));
await _ensureRevision(ref, id.description['resolved-ref']);
var revisionCachePath = _revisionCachePath(id);
await _revisionCacheClones.putIfAbsent(revisionCachePath, () async {
if (!entryExists(revisionCachePath)) {
await _clone(_repoCachePath(ref), revisionCachePath);
await _checkOut(revisionCachePath, id.description['resolved-ref']);
_writePackageList(revisionCachePath, [id.description['path']]);
} else {
_updatePackageList(revisionCachePath, id.description['path']);
return new Package.load(id.name,
p.join(revisionCachePath, id.description['path']), systemCache.sources);
}
/// Returns the path to the revision-specific cache of [id].
String getDirectory(PackageId id) =>
p.join(_revisionCachePath(id), id.description['path']);
List<Package> getCachedPackages() {
// TODO(keertip): Implement getCachedPackages().
throw new UnimplementedError(
"The git source doesn't support listing its cached packages yet.");
}
rnystrom@google.com
committed
/// Resets all cached packages back to the pristine state of the Git
/// repository at the revision they are pinned to.
Future<Pair<List<PackageId>, List<PackageId>>> repairCachedPackages() async {
if (!dirExists(systemCacheRoot)) return new Pair([], []);
rnystrom@google.com
committed
var successes = <PackageId>[];
var failures = <PackageId>[];
rnystrom@google.com
committed
var packages = listDir(systemCacheRoot)
.where((entry) => dirExists(p.join(entry, ".git")))
.expand((revisionCachePath) {
return _readPackageList(revisionCachePath).map((relative) {
// If we've already failed to load another package from this
// repository, ignore it.
if (!dirExists(revisionCachePath)) return null;
var packageDir = p.join(revisionCachePath, relative);
try {
return new Package.load(null, packageDir, systemCache.sources);
} catch (error, stackTrace) {
log.error("Failed to load package", error, stackTrace);
var name = p.basename(revisionCachePath).split('-').first;
failures.add(new PackageId(name, source, Version.none, '???'));
tryDeleteEntry(revisionCachePath);
return null;
}
});
})
.where((package) => package != null)
rnystrom@google.com
committed
// Note that there may be multiple packages with the same name and version
// (pinned to different commits). The sort order of those is unspecified.
packages.sort(Package.orderByNameAndVersion);
for (var package in packages) {
// If we've already failed to repair another package in this repository,
// ignore it.
if (!dirExists(package.dir)) continue;
var id = new PackageId(package.name, source, package.version, null);
rnystrom@google.com
committed
log.message("Resetting Git repository for "
"${log.bold(package.name)} ${package.version}...");
try {
// Remove all untracked files.
await git
.run(["clean", "-d", "--force", "-x"], workingDir: package.dir);
rnystrom@google.com
committed
// Discard all changes to tracked files.
await git.run(["reset", "--hard", "HEAD"], workingDir: package.dir);
successes.add(id);
} on git.GitException catch (error, stackTrace) {
rnystrom@google.com
committed
log.error("Failed to reset ${log.bold(package.name)} "
"${package.version}. Error:\n$error");
log.fine(stackTrace);
failures.add(id);
// Delete the revision cache path, not the subdirectory that contains the package.
tryDeleteEntry(getDirectory(id));
}
}
return new Pair(successes, failures);
rnystrom@google.com
committed
}
/// Ensures that the canonical clone of the repository referred to by [ref]
/// contains the given Git [revision].
Future _ensureRevision(PackageRef ref, String revision) async {
var path = _repoCachePath(ref);
if (_updatedRepos.contains(path)) return;
if (!entryExists(path)) await _createRepoCache(ref);
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
// Try to list the revision. If it doesn't exist, git will fail and we'll
// know we have to update the repository.
try {
await _firstRevision(path, revision);
} on git.GitException catch (_) {
await _updateRepoCache(ref);
}
}
/// Ensures that the canonical clone of the repository referred to by [ref]
/// exists and is up-to-date.
Future _ensureRepoCache(PackageRef ref) async {
var path = _repoCachePath(ref);
if (_updatedRepos.contains(path)) return;
if (!entryExists(path)) {
await _createRepoCache(ref);
} else {
await _updateRepoCache(ref);
}
}
/// Creates the canonical clone of the repository referred to by [ref].
///
/// This assumes that the canonical clone doesn't yet exist.
Future _createRepoCache(PackageRef ref) async {
var path = _repoCachePath(ref);
assert(!_updatedRepos.contains(path));
await _clone(ref.description['url'], path, mirror: true);
_updatedRepos.add(path);
/// Runs "git fetch" in the canonical clone of the repository referred to by
///
/// This assumes that the canonical clone already exists.
Future _updateRepoCache(PackageRef ref) async {
var path = _repoCachePath(ref);
if (_updatedRepos.contains(path)) return new Future.value();
await git.run(["fetch"], workingDir: path);
_updatedRepos.add(path);
/// Updates the package list file in [revisionCachePath] to include [path], if
/// necessary.
void _updatePackageList(String revisionCachePath, String path) {
var packages = _readPackageList(revisionCachePath);
if (packages.contains(path)) return;
_writePackageList(revisionCachePath, packages..add(path));
}
/// Returns the list of packages in [revisionCachePath].
List<String> _readPackageList(String revisionCachePath) {
var path = _packageListPath(revisionCachePath);
// If there's no package list file, this cache was created by an older
// version of pub where pubspecs were only allowed at the root of the
// repository.
return readTextFile(path).split("\n");
}
/// Writes a package list indicating that [packages] exist in
/// [revisionCachePath].
void _writePackageList(String revisionCachePath, List<String> packages) {
writeTextFile(_packageListPath(revisionCachePath), packages.join('\n'));
}
/// The path in a revision cache repository in which we keep a list of the
/// packages in the repository.
String _packageListPath(String revisionCachePath) =>
p.join(revisionCachePath, '.git/pub-packages');
/// Runs "git rev-list" on [reference] in [path] and returns the first result.
///
/// This assumes that the canonical clone already exists.
Future<String> _firstRevision(String path, String reference) async {
var lines = await git
.run(["rev-list", "--max-count=1", reference], workingDir: path);
return lines.first;
/// Clones the repo at the URI [from] to the path [to] on the local
/// filesystem.
///
/// If [mirror] is true, creates a bare, mirrored clone. This doesn't check
/// out the working tree, but instead makes the repository a local mirror of
/// the remote repository. See the manpage for `git clone` for more
/// information.
///
/// 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}) {
// Git on Windows does not seem to automatically create the destination
// directory.
ensureDir(to);
var args = ["clone", from, to];
if (shallow) args.insertAll(1, ["--depth", "1"]);
return git.run(args);
}).then((result) => null);
/// Checks out the reference [ref] in [repoPath].
Future _checkOut(String repoPath, String ref) {
return git
.run(["checkout", ref], workingDir: repoPath).then((result) => null);
String _revisionCachePath(PackageId id) => p.join(
systemCacheRoot, "${_repoName(id)}-${id.description['resolved-ref']}");
/// Returns the path to the canonical clone of the repository referred to by
/// [id] (the one in `<system cache>/git/cache`).
String _repoCachePath(PackageRef ref) {
var repoCacheName = '${_repoName(ref)}-${sha1(ref.description['url'])}';
return p.join(systemCacheRoot, 'cache', repoCacheName);
/// Returns a short, human-readable name for the repository URL in [packageName].
///
/// This name is not guaranteed to be unique.
String _repoName(PackageName packageName) {
var name = p.url.basename(packageName.description['url']);
if (name.endsWith('.git')) {
name = name.substring(0, name.length - '.git'.length);
}