Introduce .pubignore (#2787)
diff --git a/lib/src/command/lish.dart b/lib/src/command/lish.dart
index 4e77f8b..f26259d 100644
--- a/lib/src/command/lish.dart
+++ b/lib/src/command/lish.dart
@@ -140,7 +140,7 @@
'pubspec.');
}
- var files = entrypoint.root.listFiles(useGitIgnore: true);
+ var files = entrypoint.root.listFiles();
log.fine('Archiving and publishing ${entrypoint.root}.');
// Show the package contents so the user can verify they look OK.
diff --git a/lib/src/ignore.dart b/lib/src/ignore.dart
new file mode 100644
index 0000000..ceef982
--- /dev/null
+++ b/lib/src/ignore.dart
@@ -0,0 +1,560 @@
+// 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.
+
+/// Implements an [Ignore] filter compatible with `.gitignore`.
+///
+/// An [Ignore] instance holds a set of [`.gitignore` rules][1], and allows
+/// testing if a given path is ignored.
+///
+/// **Example**:
+/// ```dart
+/// import 'package:ignore/ignore.dart';
+///
+/// void main() {
+/// final ignore = Ignore([
+/// '*.o',
+/// ]);
+///
+/// print(ignore.ignores('main.o')); // true
+/// print(ignore.ignores('main.c')); // false
+/// }
+/// ```
+///
+/// For a generic walk of a file-hierarchy with ignore files at all levels see
+/// [Ignore.listFiles].
+///
+/// [1]: https://git-scm.com/docs/gitignore
+
+import 'package:meta/meta.dart';
+
+/// A set of ignore rules representing a single ignore file.
+///
+/// An [Ignore] instance holds [`.gitignore` rules][1] relative to a given path.
+///
+/// **Example**:
+/// ```dart
+/// import 'package:ignore/ignore.dart';
+///
+/// void main() {
+/// final ignore = Ignore([
+/// '*.o',
+/// ]);
+///
+/// print(ignore.ignores('main.o')); // true
+/// print(ignore.ignores('main.c')); // false
+/// }
+/// ```
+///
+/// [1]: https://git-scm.com/docs/gitignore
+@sealed
+class Ignore {
+ final List<_IgnoreRule> _rules;
+
+ /// Create an [Ignore] instance with a set of [`.gitignore` compatible][1]
+ /// patterns.
+ ///
+ /// Each value in [patterns] will be interpreted as one or more lines from
+ /// a `.gitignore` file, in compliance with the [`.gitignore` manual page][1].
+ ///
+ /// The keys of 'pattern' are the directories to intpret the rules relative
+ /// to. The root should be the empty string, and sub-directories are separated
+ /// by '/' (but no final '/').
+ ///
+ /// If [ignoreCase] is `true`, patterns will be case-insensitive. By default
+ /// `git` is case-sensitive. But case insensitivity can be enabled when a
+ /// repository is created, or by configuration option, see
+ /// [`core.ignoreCase` documentation][2] for details.
+ ///
+ /// If [onInvalidPattern] is passed, it will be called with a
+ /// [FormatException] describing the problem. The exception will have [source]
+ /// as source.
+ ///
+ /// **Example**:
+ /// ```dart
+ /// import 'package:ignore/ignore.dart';
+ /// void main() {
+ /// final ignore = Ignore({'': [
+ /// // You can pass an entire .gitignore file as a single string.
+ /// // You can also pass it as a list of lines, or both.
+ /// '''
+ /// # Comment in a .gitignore file
+ /// obj/
+ /// *.o
+ /// !main.o
+ /// '''
+ /// }]);
+ ///
+ /// print(ignore.ignores('obj/README.md')); // true
+ /// print(ignore.ignores('lib.o')); // false
+ /// print(ignore.ignores('main.o')); // false
+ /// }
+ /// ```
+ ///
+ /// [1]: https://git-scm.com/docs/gitignore
+ /// [2]: https://git-scm.com/docs/git-config#Documentation/git-config.txt-coreignoreCase
+ Ignore(
+ Iterable<String> patterns, {
+ bool ignoreCase = false,
+ void Function(String pattern, FormatException exception) onInvalidPattern,
+ }) : _rules = _parseIgnorePatterns(patterns, ignoreCase,
+ onInvalidPattern: onInvalidPattern);
+
+ /// Returns `true` if [path] is ignored by the patterns used to create this
+ /// [Ignore] instance, assuming those patterns are placed at `.`.
+ ///
+ /// The [path] must be a relative path, not starting with `./`, `../`, and
+ /// must end in slash (`/`) if it is directory.
+ ///
+ /// **Example**:
+ /// ```dart
+ /// import 'package:ignore/ignore.dart';
+ ///
+ /// void main() {
+ /// final ignore = Ignore([
+ /// '*.o',
+ /// ]);
+ ///
+ /// print(ignore.ignores('main.o')); // true
+ /// print(ignore.ignores('main.c')); // false
+ /// print(ignore.ignores('lib/')); // false
+ /// print(ignore.ignores('lib/helper.o')); // true
+ /// print(ignore.ignores('lib/helper.c')); // false
+ /// }
+ /// ```
+ bool ignores(String path) {
+ ArgumentError.checkNotNull(path, 'path');
+ if (path.isEmpty) {
+ throw ArgumentError.value(path, 'path', 'must be not empty');
+ }
+ if (path.startsWith('/') ||
+ path.startsWith('./') ||
+ path.startsWith('../') ||
+ path == '.' ||
+ path == '..') {
+ throw ArgumentError.value(
+ path,
+ 'path',
+ 'must be relative, and not start with "./", "../"',
+ );
+ }
+ if (_rules.isEmpty) {
+ return false;
+ }
+ final pathWithoutSlash =
+ path.endsWith('/') ? path.substring(0, path.length - 1) : path;
+ return listFiles(
+ beneath: pathWithoutSlash,
+ includeDirs: true,
+ listDir: (dir) {
+ // List the next part of path:
+ if (dir == pathWithoutSlash) return [];
+ final startOfNext = dir.isEmpty ? 0 : dir.length + 1;
+ final nextSlash = path.indexOf('/', startOfNext);
+ return [path.substring(startOfNext, nextSlash)];
+ },
+ ignoreForDir: (dir) => dir == '' ? this : null,
+ isDir: (candidate) =>
+ path.length > candidate.length && path[candidate.length] == '/',
+ ).isEmpty;
+ }
+
+ /// Returns all the files in the tree under (and including) [beneath] not
+ /// ignored by ignore-files from [root] and down.
+ ///
+ /// Represents paths normalized using '/' as directory separator. The empty
+ /// relative path is '.', no '..' are allowed.
+ ///
+ /// [beneath] must start with [root] and even if it is a directory it should not
+ /// end with '/', if [beneath] is not provided, everything under root is
+ /// included.
+ ///
+ /// [listDir] should enumerate the immediate contents of a given directory,
+ /// returning paths including [root].
+ ///
+ /// [isDir] should return true if the argument is a directory. It will only be
+ /// queried with file-names under (and including) [beneath]
+ ///
+ /// [ignoreForDir] should retrieve the ignore rules for a single directory
+ /// or return `null` if there is no ignore rules.
+ ///
+ /// If [includeDirs] is true non-ignored directories will be included in the
+ /// result (including beneath).
+ ///
+ /// This example program lists all files under second argument that are
+ /// not ignored by .gitignore files from first argument and below:
+ ///
+ /// ```dart
+ /// import 'dart:io';
+ /// import 'package:path/path.dart' as p;
+ /// import 'package:pub/src/ignore.dart';
+ ///
+ /// void main(List<String> args) {
+ /// var root = p.normalize(args[0]);
+ /// if (root == '.') root = '';
+ /// var beneath = args.length > 1 ? p.normalize(args[1]) : root;
+ /// if (beneath == '.') beneath = '';
+ /// String resolve(String path) {
+ /// return p.joinAll([root, ...p.posix.split(path)]);
+ /// }
+ ///
+ /// Ignore.listFiles(
+ /// beneath: beneath,
+ /// listDir: (dir) => Directory(resolve(dir)).listSync().map((x) {
+ /// final relative = p.relative(x.path, from: root);
+ /// return p.posix.joinAll(p.split(relative));
+ /// }),
+ /// ignoreForDir: (dir) {
+ /// final f = File(resolve('dir/.gitignore'));
+ /// return f.existsSync() ? Ignore([f.readAsStringSync()]) : null;
+ /// },
+ /// isDir: (dir) => Directory(resolve(dir)).existsSync(),
+ /// ).forEach(print);
+ /// }
+ /// ```
+ static List<String> listFiles({
+ String beneath = '',
+ @required Iterable<String> Function(String) listDir,
+ @required Ignore Function(String) ignoreForDir,
+ @required bool Function(String) isDir,
+ bool includeDirs = false,
+ }) {
+ if (beneath.startsWith('/') ||
+ beneath.startsWith('./') ||
+ beneath.startsWith('../')) {
+ throw ArgumentError.value(
+ 'must be relative and normalized', 'beneath', beneath);
+ }
+ if (beneath.endsWith('/')) {
+ throw ArgumentError.value('must not end with /', beneath);
+ }
+ // To streamline the algorithm we represent all paths as starting with '/'
+ // and the empty path as just '/'.
+ if (beneath == '.') beneath = '';
+ beneath = '/$beneath';
+
+ // Will contain all the files that are not ignored.
+ final result = <String>[];
+ // At any given point in the search, this will contain the Ignores from
+ // directories leading up to the current entity.
+ // The single `null` aligns popping and pushing in this stack with [toVisit]
+ // below.
+ final ignoreStack = <_IgnorePrefixPair>[null];
+ // Find all ignores between './' and [beneath] (not inclusive).
+
+ // [index] points at the next '/' in the path.
+ var index = -1;
+ while ((index = beneath.indexOf('/', index + 1)) != -1) {
+ final partial = beneath.substring(0, index + 1);
+ if (_matchesStack(ignoreStack, partial)) {
+ // A directory on the way towards [beneath] was ignored. Empty result.
+ return <String>[];
+ }
+ final ignore = ignoreForDir(
+ partial == '/' ? '.' : partial.substring(1, partial.length - 1));
+ ignoreStack
+ .add(ignore == null ? null : _IgnorePrefixPair(ignore, partial));
+ }
+ // Do a depth first tree-search starting at [beneath].
+ // toVisit is a stack containing all items that are waiting to be processed.
+ final toVisit = [
+ [beneath]
+ ];
+ while (toVisit.isNotEmpty) {
+ final topOfStack = toVisit.last;
+ if (topOfStack.isEmpty) {
+ toVisit.removeLast();
+ ignoreStack.removeLast();
+ continue;
+ }
+ final current = topOfStack.removeLast();
+ // This is the version of current we present to the callbacks and in
+ // [result].
+ //
+ // The empty path is represented as '.' and there is no leading '/'.
+ final normalizedCurrent = current == '/' ? '.' : current.substring(1);
+ final currentIsDir = isDir(normalizedCurrent);
+ if (_matchesStack(ignoreStack, currentIsDir ? '$current/' : current)) {
+ // current was ignored. Continue with the next item.
+ continue;
+ }
+ if (currentIsDir) {
+ final ignore = ignoreForDir(normalizedCurrent);
+ ignoreStack
+ .add(ignore == null ? null : _IgnorePrefixPair(ignore, current));
+ // Put all entities in current on the stack to be processed.
+ toVisit.add(listDir(normalizedCurrent).map((x) => '/$x').toList());
+ if (includeDirs) {
+ result.add(normalizedCurrent);
+ }
+ } else {
+ result.add(normalizedCurrent);
+ }
+ }
+ return result;
+ }
+}
+
+class _IgnoreParseResult {
+ // The parsed pattern.
+ final String pattern;
+
+ // The resulting matching rule. `null` if the pattern was empty or invalid.
+ final _IgnoreRule rule;
+
+ // An invalid pattern is also considered empty.
+ bool get empty => rule == null;
+ bool get valid => exception == null;
+
+ // For invalid patterns this contains a description of the problem.
+ final FormatException exception;
+
+ _IgnoreParseResult(this.pattern, this.rule) : exception = null;
+ _IgnoreParseResult.invalid(this.pattern, this.exception) : rule = null;
+ _IgnoreParseResult.empty(this.pattern)
+ : rule = null,
+ exception = null;
+}
+
+class _IgnoreRule {
+ /// A regular expression that represents this rule.
+ final RegExp pattern;
+ final bool negative;
+
+ /// The String this pattern was generated from.
+ final String original;
+
+ _IgnoreRule(this.pattern, this.negative, this.original);
+
+ @override
+ String toString() {
+ // TODO: implement toString
+ return '$original -> $pattern';
+ }
+}
+
+/// [onInvalidPattern] can be used to handle parse failures. If
+/// [onInvalidPattern] is `null` invalid patterns are ignored.
+List<_IgnoreRule> _parseIgnorePatterns(
+ Iterable<String> patterns,
+ bool ignoreCase, {
+ void Function(String pattern, FormatException exception) onInvalidPattern,
+}) {
+ ArgumentError.checkNotNull(patterns, 'patterns');
+ ArgumentError.checkNotNull(ignoreCase, 'ignoreCase');
+
+ final parsedPatterns = patterns
+ .map((s) => s.split('\n'))
+ .expand((e) => e)
+ .map((pattern) => _parseIgnorePattern(pattern, ignoreCase));
+ if (onInvalidPattern != null) {
+ for (final invalidResult
+ in parsedPatterns.where((result) => !result.valid)) {
+ onInvalidPattern(invalidResult.pattern, invalidResult.exception);
+ }
+ }
+ return parsedPatterns.where((r) => !r.empty).map((r) => r.rule).toList();
+}
+
+_IgnoreParseResult _parseIgnorePattern(String pattern, bool ignoreCase) {
+ // Check if patterns is a comment
+ if (pattern.startsWith('#')) {
+ return _IgnoreParseResult.empty(pattern);
+ }
+ var first = 0;
+ var end = pattern.length;
+
+ // Detect negative patterns
+ final negative = pattern.startsWith('!');
+ if (negative) {
+ first++;
+ }
+
+ // Remove trailing whitespace unless escaped
+ while (end != 0 &&
+ pattern[end - 1] == ' ' &&
+ (end == 1 || pattern[end - 2] != '\\')) {
+ end--;
+ }
+ // Empty patterns match nothing.
+ if (first == end) return _IgnoreParseResult.empty(pattern);
+
+ var current = first;
+ String peekChar() => current >= end ? null : pattern[current];
+
+ var expr = '';
+
+ // Parses the inside of a [] range. Returns the value as a RegExp character
+ // range, or null if the pattern was broken.
+ String parseCharacterRange() {
+ var characterRange = '';
+ var first = true;
+ for (;;) {
+ final nextChar = peekChar();
+ if (nextChar == null) {
+ return null;
+ }
+ current++;
+ if (nextChar == '\\') {
+ final escaped = peekChar();
+ if (escaped == null) {
+ return null;
+ }
+ current++;
+
+ characterRange += escaped == '-' ? r'\-' : RegExp.escape(escaped);
+ } else if (nextChar == '!' && first) {
+ characterRange += '^';
+ } else if (nextChar == ']' && first) {
+ characterRange += RegExp.escape(nextChar);
+ } else if (nextChar == ']') {
+ assert(!first);
+ return characterRange;
+ } else {
+ characterRange += nextChar;
+ }
+ first = false;
+ }
+ }
+
+ var relativeToPath = false;
+ var matchesDirectoriesOnly = false;
+
+ // slashes have different significance depending on where they are in
+ // the String. Handle that here.
+ void handleSlash() {
+ if (current == end) {
+ // A slash at the end makes us only match directories.
+ matchesDirectoriesOnly = true;
+ } else {
+ // A slash anywhere else makes the pattern relative anchored at the
+ // current path.
+ relativeToPath = true;
+ }
+ }
+
+ for (;;) {
+ final nextChar = peekChar();
+ if (nextChar == null) break;
+ current++;
+ if (nextChar == '*') {
+ if (peekChar() == '*') {
+ // Handle '**'
+ current++;
+ if (peekChar() == '/') {
+ current++;
+ if (current == end) {
+ expr += '.*';
+ } else {
+ // Match nothing or a path followed by '/'
+ expr += '(?:(?:)|(?:.*/))';
+ }
+ // Handle the side effects of seeing a slash.
+ handleSlash();
+ } else {
+ expr += '.*';
+ }
+ } else {
+ // Handle a single '*'
+ expr += '[^/]*';
+ }
+ } else if (nextChar == '?') {
+ // Handle '?'
+ expr += '[^/]';
+ } else if (nextChar == '[') {
+ // Character ranges
+ final characterRange = parseCharacterRange();
+ if (characterRange == null) {
+ return _IgnoreParseResult.invalid(
+ pattern,
+ FormatException(
+ 'Pattern "$pattern" had an invalid `[a-b]` style character range',
+ pattern,
+ current),
+ );
+ }
+ expr += '[$characterRange]';
+ } else if (nextChar == '\\') {
+ // Escapes
+ final escaped = peekChar();
+ if (escaped == null) {
+ return _IgnoreParseResult.invalid(
+ pattern,
+ FormatException(
+ 'Pattern "$pattern" end of pattern inside character escape.',
+ pattern,
+ current),
+ );
+ }
+ expr += RegExp.escape(escaped);
+ current++;
+ } else {
+ if (nextChar == '/') {
+ if (current - 1 != first && current != end) {
+ // If slash appears in the beginning we don't want it manifest in the
+ // regexp.
+ expr += '/';
+ }
+ handleSlash();
+ } else {
+ expr += RegExp.escape(nextChar);
+ }
+ }
+ }
+ if (relativeToPath) {
+ expr = '^$expr';
+ } else {
+ expr = '(?:^|/)$expr';
+ }
+ if (matchesDirectoriesOnly) {
+ expr = '$expr/\$';
+ } else {
+ expr = '$expr/?\$';
+ // expr = '$expr\$';
+ }
+ try {
+ return _IgnoreParseResult(
+ pattern,
+ _IgnoreRule(
+ RegExp(expr, caseSensitive: !ignoreCase), negative, pattern));
+ } on FormatException catch (e) {
+ throw AssertionError(
+ 'Created broken expression "$expr" from ignore pattern "$pattern" -> $e');
+ }
+}
+
+/// A [Ignore] object, paired with the prefix where it is found in the directory
+/// hierarchy.
+class _IgnorePrefixPair {
+ final Ignore ignore;
+ final String prefix;
+ _IgnorePrefixPair(this.ignore, this.prefix);
+ @override
+ String toString() {
+ // TODO: implement toString
+ return '{${ignore._rules.map((r) => r.original)} ${prefix}}';
+ }
+}
+
+/// Returns true if any of [ignores] has a match of [path] that is not negated
+/// by a later one.
+///
+/// expects [path] to start with '/'
+///
+/// If [path] should be matched as a directory, it should end with '/'.
+bool _matchesStack(List<_IgnorePrefixPair> ignores, String path) {
+ // This is optimized by trying the rules in reverse order.
+ // If a rule matches, the result is true if the rule is not negative.
+ for (final ignorePair in ignores.reversed) {
+ if (ignorePair == null) continue;
+ final prefixLength = ignorePair.prefix.length;
+ final s =
+ prefixLength == 0 ? path : path.substring(ignorePair.prefix.length);
+ for (final rule in ignorePair.ignore._rules.reversed) {
+ if (rule.pattern.hasMatch(s)) {
+ return !rule.negative;
+ }
+ }
+ }
+ return false;
+}
diff --git a/lib/src/io.dart b/lib/src/io.dart
index 23d1add..b9bf573 100644
--- a/lib/src/io.dart
+++ b/lib/src/io.dart
@@ -263,6 +263,10 @@
return tempDir.resolveSymbolicLinksSync();
}
+String resolveSymlinksOfDir(String dir) {
+ return Directory(dir).resolveSymbolicLinksSync();
+}
+
/// Lists the contents of [dir].
///
/// If [recursive] is `true`, lists subdirectory contents (defaults to `false`).
@@ -844,7 +848,6 @@
// Regular file
deleteIfLink(filePath);
ensureDir(parentDirectory);
-
await _createFileFromStream(entry.contents, filePath);
if (Platform.isLinux || Platform.isMacOS) {
diff --git a/lib/src/package.dart b/lib/src/package.dart
index 39666ba..55ce4fe 100644
--- a/lib/src/package.dart
+++ b/lib/src/package.dart
@@ -5,10 +5,13 @@
import 'dart:io';
import 'package:path/path.dart' as p;
+import 'package:pub/src/exceptions.dart';
import 'package:pub_semver/pub_semver.dart';
import 'git.dart' as git;
+import 'ignore.dart';
import 'io.dart';
+import 'log.dart' as log;
import 'package_name.dart';
import 'pubspec.dart';
import 'source_registry.dart';
@@ -88,7 +91,7 @@
/// pub.dartlang.org for choosing the primary one: the README with the fewest
/// extensions that is lexically ordered first is chosen.
String get readmePath {
- var readmes = listFiles(recursive: false, useGitIgnore: true)
+ var readmes = listFiles(recursive: false)
.map(p.basename)
.where((entry) => entry.contains(_readmeRegexp));
if (readmes.isEmpty) return null;
@@ -105,7 +108,7 @@
/// Returns the path to the CHANGELOG file at the root of the entrypoint, or
/// null if no CHANGELOG file is found.
String get changelogPath {
- return listFiles(recursive: false, useGitIgnore: true).firstWhere(
+ return listFiles(recursive: false).firstWhere(
(entry) => p.basename(entry).contains(_changelogRegexp),
orElse: () => null);
}
@@ -186,14 +189,15 @@
}
}
- /// The basenames of files that are included in [list] despite being hidden.
- static const _allowedFiles = ['.htaccess'];
-
- /// A set of patterns that match paths to disallowed files.
- static final _disallowedFiles = createFileFilter(['pubspec.lock']);
-
- /// A set of patterns that match paths to disallowed directories.
- static final _disallowedDirs = createDirectoryFilter(['packages']);
+ static final _basicIgnoreRules = [
+ '.*', // Don't include dot-files.
+ '!.htaccess', // Include .htaccess anyways.
+ // TODO(sigurdm): consider removing this. `packages` folders are not used
+ // anymore.
+ 'packages/',
+ 'pubspec.lock',
+ '!pubspec.lock/', // We allow a directory called pubspec lock.
+ ];
/// Returns a list of files that are considered to be part of this package.
///
@@ -202,120 +206,72 @@
/// [recursive] is true, this will return all files beneath that path;
/// otherwise, it will only return files one level beneath it.
///
- /// If [useGitIgnore] is passed, this will take the .gitignore rules into
- /// account if the root directory of the package is (or is contained within) a
- /// Git repository.
+ /// This will take .pubignore and .gitignore files into account. For each
+ /// directory a .pubignore takes precedence over a .gitignore.
///
/// Note that the returned paths won't always be beneath [dir]. To safely
/// convert them to paths relative to the package root, use [relative].
- List<String> listFiles(
- {String beneath, bool recursive = true, bool useGitIgnore = false}) {
+ List<String> listFiles({String beneath, bool recursive = true}) {
// An in-memory package has no files.
if (dir == null) return [];
-
- if (beneath == null) {
- beneath = dir;
- } else {
- beneath = p.join(dir, beneath);
+ beneath = beneath == null ? '.' : p.toUri(p.normalize(beneath)).path;
+ String resolve(String path) {
+ if (Platform.isWindows) {
+ return p.joinAll([dir, ...p.posix.split(path)]);
+ }
+ return p.join(dir, path);
}
- if (!dirExists(beneath)) return [];
+ return Ignore.listFiles(
+ beneath: beneath,
+ listDir: (dir) {
+ var contents = Directory(resolve(dir)).listSync();
+ if (!recursive) {
+ contents = contents.where((entity) => entity is! Directory).toList();
+ }
+ return contents.map((entity) {
+ if (linkExists(entity.path)) {
+ final target = Link(entity.path).targetSync();
+ if (dirExists(entity.path)) {
+ throw DataException(
+ '''Pub does not support publishing packages with directory symlinks: `${entity.path}`.''');
+ }
+ if (!fileExists(entity.path)) {
+ throw DataException(
+ '''Pub does not support publishing packages with non-resolving symlink: `${entity.path}` => `$target`.''');
+ }
+ }
+ final relative = p.relative(entity.path, from: this.dir);
+ if (Platform.isWindows) {
+ return p.posix.joinAll(p.split(relative));
+ }
+ return relative;
+ });
+ },
+ ignoreForDir: (dir) {
+ final pubIgnore = resolve('$dir/.pubignore');
+ final gitIgnore = resolve('$dir/.gitignore');
+ final ignoreFile = fileExists(pubIgnore)
+ ? pubIgnore
+ : (fileExists(gitIgnore) ? gitIgnore : null);
- // This is used in some performance-sensitive paths and can list many, many
- // files. As such, it leans more heavily towards optimization as opposed to
- // readability than most code in pub. In particular, it avoids using the
- // path package, since re-parsing a path is very expensive relative to
- // string operations.
- Iterable<String> files;
- if (useGitIgnore && inGitRepo) {
- // List all files that aren't gitignored, including those not checked in
- // to Git. Use [beneath] as the working dir rather than passing it as a
- // parameter so that we list a submodule using its own git logic.
- files = git.runSync(
- ['ls-files', '--cached', '--others', '--exclude-standard'],
- workingDir: beneath);
-
- // If we're not listing recursively, strip out paths that contain
- // separators. Since git always prints forward slashes, we always detect
- // them.
- if (!recursive) files = files.where((file) => !file.contains('/'));
-
- // Git prints files relative to [beneath], but we want them relative to
- // the pub's working directory. It also prints forward slashes on Windows
- // which we normalize away for easier testing.
- //
- // Git lists empty directories as "./", which we skip so we don't keep
- // trying to recurse into the same directory. Normally git doesn't allow
- // totally empty directories, but a submodule that's not checked out
- // behaves like one.
- files = files.where((file) => file != './').map((file) {
- return Platform.isWindows
- ? "$beneath\\${file.replaceAll("/", "\\")}"
- : '$beneath/$file';
- }).expand((file) {
- if (fileExists(file)) return [file];
- if (!dirExists(file)) return [];
-
- // `git ls-files` only returns files, except in the case of a submodule
- // or a symlink to a directory.
- return recursive ? _listWithinDir(file) : [file];
- });
- } else {
- files = listDir(beneath,
- recursive: recursive, includeDirs: false, allowed: _allowedFiles);
- }
-
- return files.where((file) {
- // Using substring here is generally problematic in cases where dir has
- // one or more trailing slashes. If you do listDir("foo"), you'll get back
- // paths like "foo/bar". If you do listDir("foo/"), you'll get "foo/bar"
- // (note the trailing slash was dropped. If you do listDir("foo//"),
- // you'll get "foo//bar".
- //
- // This means if you strip off the prefix, the resulting string may have a
- // leading separator (if the prefix did not have a trailing one) or it may
- // not. However, since we are only using the results of that to call
- // contains() on, the leading separator is harmless.
- assert(file.startsWith(beneath));
- file = file.substring(beneath.length);
- return !_disallowedFiles.any(file.endsWith) &&
- !_disallowedDirs.any(file.contains);
- }).toList();
+ final rules = [
+ if (dir == '.') ..._basicIgnoreRules,
+ if (ignoreFile != null) readTextFile(ignoreFile),
+ ];
+ return rules.isEmpty
+ ? null
+ : Ignore(
+ rules,
+ onInvalidPattern: (pattern, exception) {
+ log.warning(
+ '$ignoreFile had invalid pattern $pattern. ${exception.message}');
+ },
+ );
+ },
+ isDir: (dir) => dirExists(resolve(dir)),
+ ).map(resolve).toList();
}
-
- /// List all files recursively beneath [dir], which should be either a symlink
- /// to a directory or a git submodule.
- ///
- /// This is used by [list] when listing a Git repository, since `git ls-files`
- /// can't natively follow symlinks and (as of Git 2.12.0-rc1) can't use
- /// `--recurse-submodules` in conjunction with `--other`.
- Iterable<String> _listWithinDir(String subdir) {
- assert(dirExists(subdir));
- assert(p.isWithin(dir, subdir));
-
- var target = Directory(subdir).resolveSymbolicLinksSync();
-
- List<String> targetFiles;
- if (p.isWithin(dir, target)) {
- // If the link points within this repo, use git to list the target
- // location so we respect .gitignore.
- targetFiles =
- listFiles(beneath: p.relative(target, from: dir), useGitIgnore: true);
- } else {
- // If the link points outside this repo, just use the default listing
- // logic.
- targetFiles = listDir(target,
- recursive: true, includeDirs: false, allowed: _allowedFiles);
- }
-
- // Re-write the paths so they're underneath the symlink.
- return targetFiles.map(
- (targetFile) => p.join(subdir, p.relative(targetFile, from: target)));
- }
-
- /// Returns a debug string for the package.
- @override
- String toString() => '$name $version ($dir)';
}
/// The type of dependency from one package to another.
diff --git a/lib/src/validator.dart b/lib/src/validator.dart
index a23a94c..c4ba3e5 100644
--- a/lib/src/validator.dart
+++ b/lib/src/validator.dart
@@ -19,6 +19,7 @@
import 'validator/executable.dart';
import 'validator/flutter_constraint.dart';
import 'validator/flutter_plugin_format.dart';
+import 'validator/gitignore.dart';
import 'validator/language_version.dart';
import 'validator/license.dart';
import 'validator/name.dart';
@@ -122,6 +123,7 @@
Entrypoint entrypoint, Future<int> packageSize, String serverUrl,
{List<String> hints, List<String> warnings, List<String> errors}) {
var validators = [
+ GitignoreValidator(entrypoint),
PubspecValidator(entrypoint),
LicenseValidator(entrypoint),
NameValidator(entrypoint),
diff --git a/lib/src/validator/compiled_dartdoc.dart b/lib/src/validator/compiled_dartdoc.dart
index 077997b..3f3376a 100644
--- a/lib/src/validator/compiled_dartdoc.dart
+++ b/lib/src/validator/compiled_dartdoc.dart
@@ -18,7 +18,7 @@
@override
Future validate() {
return Future.sync(() {
- for (var entry in entrypoint.root.listFiles(useGitIgnore: true)) {
+ for (var entry in entrypoint.root.listFiles()) {
if (path.basename(entry) != 'nav.json') continue;
var dir = path.dirname(entry);
diff --git a/lib/src/validator/executable.dart b/lib/src/validator/executable.dart
index 4ce3748..2163e8c 100644
--- a/lib/src/validator/executable.dart
+++ b/lib/src/validator/executable.dart
@@ -17,7 +17,7 @@
@override
Future validate() async {
var binFiles = entrypoint.root
- .listFiles(beneath: 'bin', recursive: false, useGitIgnore: true)
+ .listFiles(beneath: 'bin', recursive: false)
.map(entrypoint.root.relative)
.toList();
diff --git a/lib/src/validator/gitignore.dart b/lib/src/validator/gitignore.dart
new file mode 100644
index 0000000..2c77c93
--- /dev/null
+++ b/lib/src/validator/gitignore.dart
@@ -0,0 +1,75 @@
+// Copyright (c) 2021, 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 'dart:io';
+
+import 'package:path/path.dart' as p;
+
+import '../entrypoint.dart';
+import '../git.dart' as git;
+import '../ignore.dart';
+import '../io.dart';
+import '../utils.dart';
+import '../validator.dart';
+
+/// A validator that validates that no checked in files are ignored by a
+/// .gitignore. These would be considered part of the package by previous
+/// versions of pub.
+class GitignoreValidator extends Validator {
+ GitignoreValidator(Entrypoint entrypoint) : super(entrypoint);
+
+ @override
+ Future<void> validate() async {
+ if (entrypoint.root.inGitRepo) {
+ final checkedIntoGit = git.runSync([
+ 'ls-files',
+ '--cached',
+ '--exclude-standard',
+ '--recurse-submodules'
+ ], workingDir: entrypoint.root.dir);
+ String resolve(String path) {
+ if (Platform.isWindows) {
+ return p.joinAll([entrypoint.root.dir, ...p.posix.split(path)]);
+ }
+ return p.join(entrypoint.root.dir, path);
+ }
+
+ final unignoredByGitignore = Ignore.listFiles(
+ listDir: (dir) {
+ var contents = Directory(resolve(dir)).listSync();
+ return contents.map((entity) => p.posix.joinAll(
+ p.split(p.relative(entity.path, from: entrypoint.root.dir))));
+ },
+ ignoreForDir: (dir) {
+ final gitIgnore = resolve('$dir/.gitignore');
+ final rules = [
+ if (fileExists(gitIgnore)) readTextFile(gitIgnore),
+ ];
+ return rules.isEmpty ? null : Ignore(rules);
+ },
+ isDir: (dir) => dirExists(resolve(dir)),
+ ).toSet();
+
+ final ignoredFilesCheckedIn = checkedIntoGit
+ .where((file) => !unignoredByGitignore.contains(file))
+ .toList();
+
+ if (ignoredFilesCheckedIn.isNotEmpty) {
+ warnings.add('''
+${ignoredFilesCheckedIn.length} checked in ${pluralize('file', ignoredFilesCheckedIn.length)} are ignored by a `.gitignore`.
+Previous versions of Pub would include those in the published package.
+
+Consider adjusting your `.gitignore` files to not ignore those files, and if you do not wish to
+publish these files use `.pubignore`. See also dart.dev/go/pubignore
+
+Files that are checked in while gitignored:
+
+${ignoredFilesCheckedIn.take(10).join('\n')}
+${ignoredFilesCheckedIn.length > 10 ? '...' : ''}
+''');
+ }
+ }
+ }
+}
diff --git a/lib/src/validator/license.dart b/lib/src/validator/license.dart
index 1646b11..1a2c68a 100644
--- a/lib/src/validator/license.dart
+++ b/lib/src/validator/license.dart
@@ -19,7 +19,7 @@
final licenseLike =
RegExp(r'^(([a-zA-Z0-9]+[-_])?(LICENSE|COPYING)|UNLICENSE)(\..*)?$');
final candidates = entrypoint.root
- .listFiles(recursive: false, useGitIgnore: true)
+ .listFiles(recursive: false)
.map(path.basename)
.where(licenseLike.hasMatch);
if (candidates.isNotEmpty) {
diff --git a/lib/src/validator/pubspec.dart b/lib/src/validator/pubspec.dart
index 19bf22b..2a5e052 100644
--- a/lib/src/validator/pubspec.dart
+++ b/lib/src/validator/pubspec.dart
@@ -18,9 +18,10 @@
@override
Future validate() async {
- var files = entrypoint.root.listFiles(recursive: false, useGitIgnore: true);
- if (!files.any((file) => p.basename(file) == 'pubspec.yaml')) {
- errors.add('The pubspec is hidden, probably by .gitignore.');
+ var files = entrypoint.root.listFiles(recursive: false);
+ if (!files.any((file) =>
+ p.canonicalize(file) == p.canonicalize(entrypoint.pubspecPath))) {
+ errors.add('The pubspec is hidden, probably by .gitignore or pubignore.');
}
}
}
diff --git a/test/descriptor.dart b/test/descriptor.dart
index 436d1a4..3bcf9ff 100644
--- a/test/descriptor.dart
+++ b/test/descriptor.dart
@@ -32,7 +32,7 @@
TarFileDescriptor(name, contents ?? <Descriptor>[]);
/// Describes a package that passes all validation.
-Descriptor get validPackage => dir(appPath, [
+DirectoryDescriptor get validPackage => dir(appPath, [
libPubspec('test_pkg', '1.0.0', sdk: '>=1.8.0 <=2.0.0'),
file('LICENSE', 'Eh, do what you want.'),
file('README.md', "This package isn't real."),
diff --git a/test/ignore_test.dart b/test/ignore_test.dart
new file mode 100644
index 0000000..2f9a44e
--- /dev/null
+++ b/test/ignore_test.dart
@@ -0,0 +1,901 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+import 'dart:io';
+
+import 'package:test/test.dart';
+import 'package:pub/src/ignore.dart';
+
+void main() {
+ group('pub', () {
+ for (final c in testData) {
+ c.paths.forEach(
+ (path, expected) => test(
+ '${c.name}: Ignore.ignores("$path") == $expected',
+ () {
+ var hasWarning = false;
+ final pathWithoutSlash =
+ path.endsWith('/') ? path.substring(0, path.length - 1) : path;
+
+ Iterable<String> listDir(String dir) {
+ // List the next part of path:
+ if (dir == pathWithoutSlash) return [];
+ final nextSlash =
+ path.indexOf('/', dir == '.' ? 0 : dir.length + 1);
+ return [
+ path.substring(0, nextSlash == -1 ? path.length : nextSlash)
+ ];
+ }
+
+ Ignore ignoreForDir(String dir) => c.patterns[dir] == null
+ ? null
+ : Ignore(c.patterns[dir],
+ onInvalidPattern: (_, __) => hasWarning = true);
+
+ bool isDir(String candidate) =>
+ candidate == '.' ||
+ path.length > candidate.length && path[candidate.length] == '/';
+
+ final r = Ignore.listFiles(
+ beneath: pathWithoutSlash,
+ includeDirs: true,
+ listDir: listDir,
+ ignoreForDir: ignoreForDir,
+ isDir: isDir);
+ if (expected) {
+ expect(r, isEmpty,
+ reason: 'Expected "$path" to be ignored, it was NOT!');
+ } else {
+ expect(r, [pathWithoutSlash],
+ reason:
+ 'Expected "$path" to NOT be ignored, it was IGNORED!');
+ }
+
+ // Also test that the logic of walking the tree works.
+ final r2 = Ignore.listFiles(
+ includeDirs: true,
+ listDir: listDir,
+ ignoreForDir: ignoreForDir,
+ isDir: isDir);
+ if (expected) {
+ expect(r2, isNot(contains(pathWithoutSlash)),
+ reason: 'Expected "$path" to be ignored, it was NOT!');
+ } else {
+ expect(r2, contains(pathWithoutSlash),
+ reason:
+ 'Expected "$path" to NOT be ignored, it was IGNORED!');
+ }
+ expect(hasWarning, c.hasWarning);
+ },
+ ),
+ );
+ }
+ });
+
+ ProcessResult runGit(List<String> args, {String workingDirectory}) {
+ final executable = Platform.isWindows ? 'cmd' : 'git';
+ args = Platform.isWindows ? ['/c', 'git', ...args] : args;
+ return Process.runSync(executable, args,
+ workingDirectory: workingDirectory);
+ }
+
+ group(
+ 'git',
+ () {
+ Directory tmp;
+ setUpAll(() async {
+ tmp = await Directory.systemTemp.createTemp('package-ignore-test-');
+
+ final ret = runGit(['init'], workingDirectory: tmp.path);
+ expect(ret.exitCode, equals(0),
+ reason:
+ 'Running "git init" failed. StdErr: ${ret.stderr} StdOut: ${ret.stdout}');
+ });
+ tearDownAll(() async {
+ await tmp.delete(recursive: true);
+ tmp = null;
+ });
+ tearDown(() async {
+ runGit(['clean', '-f', '-d', '-x'], workingDirectory: tmp.path);
+ });
+ for (final c in testData) {
+ c.paths.forEach(
+ (path, expected) => test(
+ '${c.name}: git check-ignore "$path" is ${expected ? 'IGNORED' : 'NOT ignored'}',
+ () async {
+ for (final directory in c.patterns.keys) {
+ final resolvedDirectory =
+ directory == '' ? tmp.uri : tmp.uri.resolve(directory + '/');
+ Directory.fromUri(resolvedDirectory).createSync(recursive: true);
+ final gitIgnore =
+ File.fromUri(resolvedDirectory.resolve('.gitignore'));
+ gitIgnore
+ .writeAsStringSync(c.patterns[directory].join('\n') + '\n');
+ }
+ final process = runGit(
+ ['-C', tmp.path, 'check-ignore', '--no-index', path],
+ workingDirectory: tmp.path);
+ final exitCode = process.exitCode;
+ expect(
+ exitCode,
+ anyOf(0, 1),
+ reason: 'Running "git check-ignore" failed',
+ );
+ final ignored = exitCode == 0;
+ if (expected != ignored) {
+ if (expected) {
+ fail('Expected "$path" to be ignored, it was NOT!');
+ }
+ fail('Expected "$path" to NOT be ignored, it was IGNORED!');
+ }
+ },
+ skip: Platform.isMacOS || // System `git` on mac has issues...
+ c.skipOnWindows && Platform.isWindows),
+ );
+ }
+ },
+ );
+}
+
+class TestData {
+ /// Name of the test case.
+ final String name;
+
+ /// Patterns for the test case.
+ final Map<String, List<String>> patterns;
+
+ /// Map from path to `true` if ignored by [patterns], and `false` if not
+ /// ignored by `patterns`.
+ final Map<String, bool> paths;
+
+ final bool hasWarning;
+
+ /// Many of the tests don't play well on windows. Simply skip them.
+ final bool skipOnWindows;
+
+ TestData(
+ this.name,
+ this.patterns,
+ this.paths, {
+ this.hasWarning = false,
+ this.skipOnWindows = false,
+ });
+
+ TestData.single(
+ String pattern,
+ this.paths, {
+ this.hasWarning = false,
+ this.skipOnWindows = false,
+ }) : name = '"${pattern.replaceAll('\n', '\\n')}"',
+ patterns = {
+ '.': [pattern]
+ };
+}
+
+final testData = [
+ // Simple test case
+ TestData('simple', {
+ '.': [
+ '/.git/',
+ '*.o',
+ ]
+ }, {
+ '.git/config': true,
+ '.git/': true,
+ 'README.md': false,
+ 'main.c': false,
+ 'main.o': true,
+ }),
+ // Test empty lines
+ TestData('empty', {
+ '.': ['']
+ }, {
+ 'README.md': false,
+ }),
+ // Test simple patterns
+ TestData.single('file.txt', {
+ 'file.txt': true,
+ 'other.txt': false,
+ 'src/file.txt': true,
+ '.obj/file.txt': true,
+ 'sub/folder/file.txt': true,
+ }),
+ TestData.single('/file.txt', {
+ 'file.txt': true,
+ 'other.txt': false,
+ 'src/file.txt': false,
+ '.obj/file.txt': false,
+ 'sub/folder/file.txt': false,
+ }),
+ // Test comments and escaping
+ TestData.single('#file.txt', {
+ 'file.txt': false,
+ '#file.txt': false,
+ }),
+ TestData.single(r'\#file.txt', {
+ '#file.txt': true,
+ 'other.txt': false,
+ 'src/#file.txt': true,
+ '.obj/#file.txt': true,
+ 'sub/folder/#file.txt': true,
+ }),
+ // Test ! and escaping
+ TestData.single('!file.txt', {
+ 'file.txt': false,
+ '!file.txt': false,
+ }),
+ TestData(
+ 'negation',
+ {
+ '.': ['f*', '!file.txt']
+ },
+ {
+ 'file.txt': false,
+ '!file.txt': false,
+ 'filter.txt': true,
+ },
+ ),
+ TestData.single(r'\!file.txt', {
+ '!file.txt': true,
+ 'other.txt': false,
+ 'src/!file.txt': true,
+ '.obj/!file.txt': true,
+ 'sub/folder/!file.txt': true,
+ }),
+ // Test trailing spaces and escaping
+ TestData.single('file.txt ', {
+ 'file.txt': true,
+ 'other.txt': false,
+ 'src/file.txt': true,
+ '.obj/file.txt': true,
+ 'sub/folder/file.txt': true,
+ }),
+ TestData.single(r'file.txt\ \ ', {
+ 'file.txt ': true,
+ 'file.txt': false,
+ 'other.txt ': false,
+ 'src/file.txt ': true,
+ 'src/file.txt': false,
+ '.obj/file.txt ': true,
+ '.obj/file.txt': false,
+ 'sub/folder/file.txt ': true,
+ 'sub/folder/file.txt': false,
+ }),
+ // Test ending in a slash or not
+ TestData.single('folder/', {
+ 'file.txt': false,
+ 'folder': false,
+ 'folder/': true,
+ 'folder/file.txt': true,
+ 'sub/folder/': true,
+ 'sub/folder': false,
+ 'sub/file.txt': false,
+ }),
+ TestData.single('folder.txt/', {
+ 'file.txt': false,
+ 'folder.txt': false,
+ 'folder.txt/': true,
+ 'folder.txt/file.txt': true,
+ 'sub/folder.txt/': true,
+ 'sub/folder.txt': false,
+ 'sub/file.txt': false,
+ }),
+ TestData.single('folder', {
+ 'file.txt': false,
+ 'folder': true,
+ 'folder/': true,
+ 'folder/file.txt': true,
+ 'sub/folder/': true,
+ 'sub/folder': true,
+ 'sub/file.txt': false,
+ }),
+ TestData.single('folder.txt', {
+ 'file.txt': false,
+ 'folder.txt': true,
+ 'folder.txt/': true,
+ 'folder.txt/file.txt': true,
+ 'sub/folder.txt/': true,
+ 'sub/folder.txt': true,
+ 'sub/file.txt': false,
+ }),
+ // Test contains a slash makes it relative root
+ TestData.single('/folder/', {
+ 'file.txt': false,
+ 'folder': false,
+ 'folder/': true,
+ 'folder/file.txt': true,
+ 'sub/folder/': false,
+ 'sub/folder': false,
+ 'sub/file.txt': false,
+ }),
+ TestData.single('/folder', {
+ 'file.txt': false,
+ 'folder': true,
+ 'folder/': true,
+ 'folder/file.txt': true,
+ 'sub/folder/': false,
+ 'sub/folder': false,
+ 'sub/file.txt': false,
+ }),
+ TestData.single('sub/folder/', {
+ 'file.txt': false,
+ 'folder': false,
+ 'folder/': false,
+ 'folder/file.txt': false,
+ 'sub/folder/': true,
+ 'sub/folder/file.txt': true,
+ 'sub/folder': false,
+ 'sub/file.txt': false,
+ }),
+ TestData.single('sub/folder', {
+ 'file.txt': false,
+ 'folder': false,
+ 'folder/': false,
+ 'folder/file.txt': false,
+ 'sub/folder/': true,
+ 'sub/folder/file.txt': true,
+ 'sub/folder': true,
+ 'sub/file.txt': false,
+ }),
+ // Special characters from RegExp that are not special in .gitignore
+ for (final c in r'(){}+.^$|'.split('')) ...[
+ TestData.single(
+ '${c}file.txt',
+ {
+ '${c}file.txt': true,
+ 'file.txt': false,
+ 'file.txt$c': false,
+ },
+ skipOnWindows: c == '^' || c == '|'),
+ TestData.single(
+ 'file.txt$c',
+ {
+ 'file.txt$c': true,
+ 'file.txt': false,
+ '${c}file.txt': false,
+ },
+ skipOnWindows: c == '^' || c == '|'),
+ TestData.single(
+ 'fi${c}l)e.txt',
+ {
+ 'fi${c}l)e.txt': true,
+ 'f${c}il)e.txt': false,
+ 'fil)e.txt': false,
+ },
+ skipOnWindows: c == '^' || c == '|'),
+ TestData.single(
+ 'fi${c}l}e.txt',
+ {
+ 'fi${c}l}e.txt': true,
+ 'f${c}il}e.txt': false,
+ 'fil}e.txt': false,
+ },
+ skipOnWindows: c == '^' || c == '|'),
+ ],
+ // Special characters from RegExp that are also special in .gitignore
+ // can be escaped.
+ for (final c in r'[]*?\'.split('')) ...[
+ TestData.single(
+ '\\${c}file.txt',
+ {
+ '${c}file.txt': true,
+ 'file.txt': false,
+ 'file.txt$c': false,
+ },
+ skipOnWindows: c == r'\'),
+ TestData.single(
+ 'file.txt\\$c',
+ {
+ 'file.txt$c': true,
+ 'file.txt': false,
+ '${c}file.txt': false,
+ },
+ skipOnWindows: c == r'\'),
+ TestData.single(
+ 'fi\\${c}l)e.txt',
+ {
+ 'fi${c}l)e.txt': true,
+ 'f${c}il)e.txt': false,
+ 'fil)e.txt': false,
+ },
+ skipOnWindows: c == r'\'),
+ TestData.single(
+ 'fi\\${c}l}e.txt',
+ {
+ 'fi${c}l}e.txt': true,
+ 'f${c}il}e.txt': false,
+ 'fil}e.txt': false,
+ },
+ skipOnWindows: c == r'\'),
+ ],
+ // Special characters from RegExp can always be escaped
+ for (final c in r'()[]{}*+?.^$|\'.split('')) ...[
+ TestData.single(
+ '\\${c}file.txt',
+ {
+ '${c}file.txt': true,
+ 'file.txt': false,
+ 'file.txt$c': false,
+ },
+ skipOnWindows: c == '^' || c == '|' || c == r'\'),
+ TestData.single(
+ 'file.txt\\$c',
+ {
+ 'file.txt$c': true,
+ 'file.txt': false,
+ '${c}file.txt': false,
+ },
+ skipOnWindows: c == '^' || c == '|' || c == r'\'),
+ TestData.single(
+ 'file\\$c.txt',
+ {
+ 'file$c.txt': true,
+ 'file.txt': false,
+ '${c}file.txt': false,
+ },
+ skipOnWindows: c == '^' || c == '|' || c == r'\'),
+ ],
+ // Ending in backslash (unescaped)
+ TestData.single(
+ 'file.txt\\',
+ {
+ 'file.txt\\': false,
+ 'file.txt ': false,
+ 'file.txt\n': false,
+ 'file.txt': false,
+ },
+ hasWarning: true,
+ skipOnWindows: true),
+ TestData.single(r'file.txt\n', {
+ 'file.txt\\\n': false,
+ 'file.txt ': false,
+ 'file.txt\n': false,
+ 'file.txt': false,
+ }),
+ TestData.single(
+ '**\\',
+ {
+ 'file.txt\\\n': false,
+ 'file.txt ': false,
+ 'file.txt\n': false,
+ 'file.txt': false,
+ },
+ hasWarning: true),
+ TestData.single(
+ '*\\',
+ {
+ 'file.txt\\\n': false,
+ 'file.txt ': false,
+ 'file.txt\n': false,
+ 'file.txt': false,
+ },
+ hasWarning: true),
+ // ? matches anything except /
+ TestData.single('?', {
+ 'f': true,
+ 'file.txt': false,
+ }),
+ TestData.single('a?c', {
+ 'abc': true,
+ 'abcd': false,
+ 'a/b': false,
+ 'ab/': false,
+ 'folder': false,
+ 'folder/': false,
+ 'folder/abc': true,
+ 'folder/abcd': false,
+ 'folder/aac': true,
+ 'abc/': true,
+ 'abc/file.txt': true,
+ }),
+ TestData.single('???', {
+ 'abc': true,
+ 'abcd': false,
+ 'a/b': false,
+ 'ab/': false,
+ 'folder': false,
+ 'folder/': false,
+ 'folder/abc': true,
+ 'folder/abcd': false,
+ 'folder/aaa': true,
+ 'abc/': true,
+ 'abc/file.txt': true,
+ }),
+ TestData.single('/???', {
+ 'abc': true,
+ 'abcd': false,
+ 'a/b': false,
+ 'ab/': false,
+ 'folder': false,
+ 'folder/': false,
+ 'folder/abc': false,
+ 'folder/abcd': false,
+ 'folder/aaa': false,
+ 'abc/': true,
+ 'abc/file.txt': true,
+ }),
+ TestData.single('???/', {
+ 'abc': false,
+ 'abcd': false,
+ 'a/b': false,
+ 'ab/': false,
+ 'folder': false,
+ 'folder/': false,
+ 'folder/abc': false,
+ 'folder/abcd': false,
+ 'folder/aaa': false,
+ 'abc/': true,
+ 'abc/file.txt': true,
+ }),
+ TestData.single('???/file.txt', {
+ 'abc': false,
+ 'folder': false,
+ 'folder/': false,
+ 'folder/abc': false,
+ 'folder/abcd': false,
+ 'folder/aaa': false,
+ 'abc/': false,
+ 'abc/file.txt': true,
+ }),
+ // Empty character classes
+ TestData.single(
+ 'a[]c',
+ {
+ 'abc': false,
+ 'ac': false,
+ 'a': false,
+ 'a[]c': false,
+ 'c': false,
+ },
+ hasWarning: true),
+ TestData.single(
+ 'a[]',
+ {
+ 'abc': false,
+ 'ac': false,
+ 'a': false,
+ 'a[]': false,
+ 'c': false,
+ },
+ hasWarning: true),
+ // Invalid character classes
+ TestData.single(
+ r'a[\]',
+ {
+ 'abc': false,
+ 'ac': false,
+ 'a': false,
+ 'a\\': false,
+ 'a[]': false,
+ 'a[': false,
+ 'a[\\]': false,
+ 'c': false,
+ },
+ hasWarning: true,
+ skipOnWindows: true),
+ TestData.single(
+ r'a[\\\]',
+ {
+ 'abc': false,
+ 'ac': false,
+ 'a': false,
+ 'a[]': false,
+ 'a[': false,
+ 'a[\\]': false,
+ 'c': false,
+ },
+ hasWarning: true,
+ skipOnWindows: true),
+ // Character classes with special characters
+ TestData.single(
+ r'a[\\]',
+ {
+ 'a': false,
+ 'ab': false,
+ 'a[]': false,
+ 'a[': false,
+ 'a\\': true,
+ },
+ skipOnWindows: true),
+ TestData.single(
+ r'a[^b]',
+ {
+ 'a': false,
+ 'ab': false,
+ 'ac': true,
+ 'a[': true,
+ 'a\\': true,
+ },
+ skipOnWindows: true),
+ TestData.single(
+ r'a[!b]',
+ {
+ 'a': false,
+ 'ab': false,
+ 'ac': true,
+ 'a[': true,
+ 'a\\': true,
+ },
+ skipOnWindows: true),
+ TestData.single(r'a[[]', {
+ 'a': false,
+ 'ab': false,
+ 'a[': true,
+ 'a]': false,
+ }),
+ TestData.single(r'a[]]', {
+ 'a': false,
+ 'ab': false,
+ 'a[': false,
+ 'a]': true,
+ }),
+ TestData.single(r'a[?]', {
+ 'a': false,
+ 'ab': false,
+ 'a??': false,
+ 'a?': true,
+ }),
+ // Character classes with characters
+ TestData.single(r'a[abc]', {
+ 'a': false,
+ 'aa': true,
+ 'ab': true,
+ 'ac': true,
+ 'ad': false,
+ }),
+ // Character classes with ranges
+ TestData.single(r'a[a-c]', {
+ 'a': false,
+ 'aa': true,
+ 'ab': true,
+ 'ac': true,
+ 'ad': false,
+ 'ae': false,
+ }),
+ TestData.single(r'a[a-cf]', {
+ 'a': false,
+ 'aa': true,
+ 'ab': true,
+ 'ac': true,
+ 'ad': false,
+ 'ae': false,
+ 'af': true,
+ }),
+ TestData.single(r'a[a-cx-z]', {
+ 'a': false,
+ 'aa': true,
+ 'ab': true,
+ 'ac': true,
+ 'ad': false,
+ 'ae': false,
+ 'af': false,
+ 'ax': true,
+ 'ay': true,
+ 'az': true,
+ }),
+ // Character classes with weird-ranges
+ TestData.single(r'a[a-c-e]', {
+ 'a': false,
+ 'aa': true,
+ 'ab': true,
+ 'ac': true,
+ 'ad': false,
+ 'af': false,
+ 'ae': true,
+ 'a-': true,
+ }),
+ TestData.single(r'a[--0]', {
+ 'a': false,
+ 'a-': true,
+ 'a.': true,
+ 'a0': true,
+ 'a1': false,
+ }),
+ TestData.single(r'a[+--]', {
+ 'a': false,
+ 'a-': true,
+ 'a+': true,
+ 'a,': true,
+ 'a0': false,
+ }),
+ TestData.single(r'a[a-c]', {
+ 'a': false,
+ 'aa': true,
+ 'ab': true,
+ 'ac': true,
+ 'ad': false,
+ 'a-': false,
+ }),
+ TestData.single(r'a[\a-c]', {
+ 'a': false,
+ 'a\\': false,
+ 'aa': true,
+ 'ab': true,
+ 'ac': true,
+ 'ad': false,
+ 'a-': false,
+ }),
+ TestData.single(r'a[a-\c]', {
+ 'a': false,
+ 'a\\': false,
+ 'aa': true,
+ 'ab': true,
+ 'ac': true,
+ 'ad': false,
+ 'a-': false,
+ }),
+ TestData.single(r'a[\a-\c]', {
+ 'a': false,
+ 'a\\': false,
+ 'aa': true,
+ 'ab': true,
+ 'ac': true,
+ 'ad': false,
+ 'a-': false,
+ }),
+ TestData.single(r'a[\a\-\c]', {
+ 'a': false,
+ 'a\\': false,
+ 'aa': true,
+ 'ab': false,
+ 'a-': true,
+ 'ac': true,
+ 'ad': false,
+ }),
+ // Character classes with dashes
+ TestData.single(r'a[-]', {
+ 'a-': true,
+ 'a': false,
+ }),
+ TestData.single(r'a[a-]', {
+ 'a-': true,
+ 'aa': true,
+ 'ab': false,
+ }),
+ TestData.single(r'a[-a]', {
+ 'a-': true,
+ 'aa': true,
+ 'ab': false,
+ }),
+ // TODO: test slashes in character classes
+ // Test **, *, [, and [...] cases
+ TestData.single('x[a-c-e]', {
+ 'xa': true,
+ 'xb': true,
+ 'xc': true,
+ 'cd': false,
+ 'xe': true,
+ 'x-': true,
+ }),
+ TestData.single('*', {
+ 'file.txt': true,
+ 'other.txt': true,
+ 'src/file.txt': true,
+ '.obj/file.txt': true,
+ 'sub/folder/file.txt': true,
+ }),
+ TestData.single('f*', {
+ 'file.txt': true,
+ 'otherf.txt': false,
+ 'src/file.txt': true,
+ 'folder/other.txt': true,
+ 'sub/folder/file.txt': true,
+ }),
+ TestData.single('*f', {
+ 'file.txt': false,
+ 'otherf.txt': false,
+ 'otherf.paf': true,
+ 'src/file.txt': false,
+ 'folder/other.txt': false,
+ 'sub/folderf/file.txt': true,
+ }),
+ TestData.single('sub/**/f*', {
+ 'file.txt': false,
+ 'otherf.txt': false,
+ 'other.paf': false,
+ 'src/file.txt': false,
+ 'folder/other.txt': false,
+ 'sub/file.txt': true,
+ 'sub/f.txt': true,
+ 'sub/pile.txt': false,
+ 'sub/other.paf': false,
+ 'sub/folder/file.txt': true,
+ 'sub/folder/': true,
+ 'sub/folder/pile.txt': true,
+ 'sub/folder/other.paf': true,
+ 'sub/bolder/': false,
+ 'sub/bolder/file.txt': true,
+ 'sub/bolder/pile.txt': false,
+ 'sub/bolder/other.paf': false,
+ 'subblob/file.txt': false,
+ }),
+ TestData.single('sub/', {
+ 'sub/': true,
+ 'mop/': false,
+ 'sup': false,
+ }),
+ TestData.single('sub/**/', {
+ 'file.txt': false,
+ 'otherf.txt': false,
+ 'other.paf': false,
+ 'src/file.txt': false,
+ 'folder/other.txt': false,
+ 'sub/file.txt': false,
+ 'sub/f.txt': false,
+ 'sub/pile.txt': false,
+ 'sub/other.paf': false,
+ 'sub/folder/': true,
+ 'sub/sub/folder/': true,
+ 'sub/folder/file.txt': true,
+ 'sub/folder/pile.txt': true,
+ 'sub/folder/other.paf': true,
+ 'sub/bolder/': true,
+ 'sub/': false,
+ 'sub/bolder/file.txt': true,
+ 'sub/bolder/pile.txt': true,
+ 'sub/bolder/other.paf': true,
+ 'subblob/file.txt': false,
+ }),
+ TestData.single('**/bolder/', {
+ 'file.txt': false,
+ 'otherf.txt': false,
+ 'other.paf': false,
+ 'src/file.txt': false,
+ 'sub/folder/bolder': false,
+ 'sub/folder/other.paf': false,
+ 'sub/bolder/': true,
+ 'sub/': false,
+ 'bolder/': true,
+ 'bolder': false,
+ 'sub/bolder/file.txt': true,
+ 'sub/bolder/pile.txt': true,
+ 'sub/bolder/other.paf': true,
+ 'subblob/file.txt': false,
+ }),
+ TestData('ignores in subfolders only target those', {
+ '.': ['a.txt'],
+ 'folder': ['b.txt'],
+ 'folder/sub': ['c.txt'],
+ }, {
+ 'a.txt': true,
+ 'b.txt': false,
+ 'c.txt': false,
+ 'folder/a.txt': true,
+ 'folder/b.txt': true,
+ 'folder/c.txt': false,
+ 'folder/sub/a.txt': true,
+ 'folder/sub/b.txt': true,
+ 'folder/sub/c.txt': true,
+ }),
+ TestData('Cannot negate folders that were excluded', {
+ '.': ['sub/', '!sub/foo.txt']
+ }, {
+ 'sub/a.txt': true,
+ 'sub/foo.txt': true,
+ }),
+ TestData('Can negate the exclusion of folders', {
+ '.': ['*.txt', 'sub', '!sub', '!foo.txt'],
+ }, {
+ 'sub/a.txt': true,
+ 'sub/foo.txt': false,
+ }),
+ TestData('Can negate the exclusion of folders 2', {
+ '.': ['sub/', '*.txt'],
+ 'folder': ['!sub/', '!foo.txt']
+ }, {
+ 'folder/sub/a.txt': true,
+ 'folder/sub/foo.txt': false,
+ 'folder/foo.txt': false,
+ 'folder/a.txt': true,
+ })
+];
diff --git a/test/package_list_files_test.dart b/test/package_list_files_test.dart
index 3871ba0..07d8ef3 100644
--- a/test/package_list_files_test.dart
+++ b/test/package_list_files_test.dart
@@ -2,11 +2,13 @@
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
+import 'dart:io';
+
import 'package:path/path.dart' as p;
+import 'package:pub/src/exceptions.dart';
import 'package:test/test.dart';
import 'package:pub/src/entrypoint.dart';
-import 'package:pub/src/io.dart';
import 'package:pub/src/system_cache.dart';
import 'descriptor.dart' as d;
@@ -16,36 +18,161 @@
Entrypoint entrypoint;
void main() {
- group('not in a git repo', () {
- setUp(() async {
- await d.appDir().create();
- createEntrypoint();
- });
+ test('lists files recursively', () async {
+ await d.dir(appPath, [
+ d.pubspec({'name': 'myapp'}),
+ d.file('file1.txt', 'contents'),
+ d.file('file2.txt', 'contents'),
+ d.dir('subdir', [
+ d.file('subfile1.txt', 'subcontents'),
+ d.file('subfile2.txt', 'subcontents')
+ ]),
+ d.dir(Uri.encodeComponent('\\/%+-='), [
+ d.file(Uri.encodeComponent('\\/%+-=')),
+ ]),
+ ]).create();
+ createEntrypoint();
- test('lists files recursively', () async {
- await d.dir(appPath, [
- d.file('file1.txt', 'contents'),
- d.file('file2.txt', 'contents'),
- d.dir('subdir', [
- d.file('subfile1.txt', 'subcontents'),
- d.file('subfile2.txt', 'subcontents')
- ])
- ]).create();
-
- expect(
- entrypoint.root.listFiles(),
- unorderedEquals([
- p.join(root, 'pubspec.yaml'),
- p.join(root, 'file1.txt'),
- p.join(root, 'file2.txt'),
- p.join(root, 'subdir', 'subfile1.txt'),
- p.join(root, 'subdir', 'subfile2.txt')
- ]));
- });
-
- commonTests();
+ expect(
+ entrypoint.root.listFiles(),
+ unorderedEquals([
+ p.join(root, 'pubspec.yaml'),
+ p.join(root, 'file1.txt'),
+ p.join(root, 'file2.txt'),
+ p.join(root, 'subdir', 'subfile1.txt'),
+ p.join(root, 'subdir', 'subfile2.txt'),
+ p.join(root, Uri.encodeComponent('\\/%+-='),
+ Uri.encodeComponent('\\/%+-=')),
+ ]));
});
+ // On windows symlinks to directories are distinct from symlinks to files.
+ void createDirectorySymlink(String path, String target) {
+ if (Platform.isWindows) {
+ Process.runSync('cmd', ['/c', 'mklink', '/D', path, target]);
+ } else {
+ Link(path).createSync(target);
+ }
+ }
+
+ test('throws on directory symlinks', () async {
+ await d.dir(appPath, [
+ d.pubspec({'name': 'myapp'}),
+ d.file('file1.txt', 'contents'),
+ d.file('file2.txt', 'contents'),
+ d.dir('subdir', [
+ d.dir('a', [d.file('file')])
+ ]),
+ ]).create();
+ createDirectorySymlink(
+ p.join(d.sandbox, appPath, 'subdir', 'symlink'), 'a');
+
+ createEntrypoint();
+
+ expect(
+ () => entrypoint.root.listFiles(),
+ throwsA(
+ isA<DataException>().having(
+ (e) => e.message,
+ 'message',
+ contains(
+ 'Pub does not support publishing packages with directory symlinks',
+ ),
+ ),
+ ),
+ );
+ });
+
+ test('can list a package inside a symlinked folder', () async {
+ await d.dir(appPath, [
+ d.pubspec({'name': 'myapp'}),
+ d.file('file1.txt', 'contents'),
+ d.file('file2.txt', 'contents'),
+ d.dir('subdir', [
+ d.dir('a', [d.file('file')])
+ ]),
+ ]).create();
+
+ final root = p.join(d.sandbox, 'symlink');
+ createDirectorySymlink(root, appPath);
+
+ final entrypoint = Entrypoint(p.join(d.sandbox, 'symlink'),
+ SystemCache(rootDir: p.join(d.sandbox, cachePath)));
+
+ expect(entrypoint.root.listFiles(), {
+ p.join(root, 'pubspec.yaml'),
+ p.join(root, 'file1.txt'),
+ p.join(root, 'file2.txt'),
+ p.join(root, 'subdir', 'a', 'file'),
+ });
+ });
+
+ test('throws on non-resolving file symlinks', () async {
+ await d.dir(appPath, [
+ d.pubspec({'name': 'myapp'}),
+ d.file('file1.txt', 'contents'),
+ d.file('file2.txt', 'contents'),
+ d.dir('subdir', [
+ d.dir('a', [d.file('file')])
+ ]),
+ ]).create();
+ Link(p.join(d.sandbox, appPath, 'subdir', 'symlink'))
+ .createSync('nonexisting');
+
+ createEntrypoint();
+
+ expect(
+ () => entrypoint.root.listFiles(),
+ throwsA(
+ isA<DataException>().having(
+ (e) => e.message,
+ 'message',
+ contains(
+ 'Pub does not support publishing packages with non-resolving symlink:'),
+ ),
+ ),
+ );
+ });
+
+ test('throws on reciprocal symlinks', () async {
+ await d.dir(appPath, [
+ d.pubspec({'name': 'myapp'}),
+ d.file('file1.txt', 'contents'),
+ d.file('file2.txt', 'contents'),
+ d.dir('subdir', [
+ d.dir('a', [d.file('file')])
+ ]),
+ ]).create();
+ Link(p.join(d.sandbox, appPath, 'subdir', 'symlink1'))
+ .createSync('symlink2');
+ Link(p.join(d.sandbox, appPath, 'subdir', 'symlink2'))
+ .createSync('symlink1');
+ createEntrypoint();
+
+ expect(
+ () => entrypoint.root.listFiles(),
+ throwsA(
+ isA<DataException>().having(
+ (e) => e.message,
+ 'message',
+ contains(
+ 'Pub does not support publishing packages with non-resolving symlink:'),
+ ),
+ ),
+ );
+ });
+ test('pubignore can undo the exclusion of .-files', () async {
+ await d.dir(appPath, [
+ d.file('.pubignore', '!.foo'),
+ d.pubspec({'name': 'myapp'}),
+ d.file('.foo', ''),
+ ]).create();
+ createEntrypoint();
+ expect(entrypoint.root.listFiles(), {
+ p.join(root, '.foo'),
+ p.join(root, 'pubspec.yaml'),
+ });
+ });
group('with git', () {
d.GitRepoDescriptor repo;
setUp(() async {
@@ -65,18 +192,16 @@
])
]).create();
- expect(
- entrypoint.root.listFiles(),
- unorderedEquals([
- p.join(root, 'pubspec.yaml'),
- p.join(root, 'file1.txt'),
- p.join(root, 'file2.txt'),
- p.join(root, 'subdir', 'subfile1.txt'),
- p.join(root, 'subdir', 'subfile2.txt')
- ]));
+ expect(entrypoint.root.listFiles(), {
+ p.join(root, 'pubspec.yaml'),
+ p.join(root, 'file1.txt'),
+ p.join(root, 'file2.txt'),
+ p.join(root, 'subdir', 'subfile1.txt'),
+ p.join(root, 'subdir', 'subfile2.txt')
+ });
});
- test('ignores files that are gitignored if desired', () async {
+ test('ignores files that are gitignored', () async {
await d.dir(appPath, [
d.file('.gitignore', '*.txt'),
d.file('file1.txt', 'contents'),
@@ -87,24 +212,11 @@
])
]).create();
- expect(
- entrypoint.root.listFiles(useGitIgnore: true),
- unorderedEquals([
- p.join(root, 'pubspec.yaml'),
- p.join(root, '.gitignore'),
- p.join(root, 'file2.text'),
- p.join(root, 'subdir', 'subfile2.text')
- ]));
-
- expect(
- entrypoint.root.listFiles(),
- unorderedEquals([
- p.join(root, 'pubspec.yaml'),
- p.join(root, 'file1.txt'),
- p.join(root, 'file2.text'),
- p.join(root, 'subdir', 'subfile1.txt'),
- p.join(root, 'subdir', 'subfile2.text')
- ]));
+ expect(entrypoint.root.listFiles(), {
+ p.join(root, 'pubspec.yaml'),
+ p.join(root, 'file2.text'),
+ p.join(root, 'subdir', 'subfile2.text')
+ });
});
test(
@@ -125,24 +237,11 @@
createEntrypoint(p.join(appPath, 'sub'));
- expect(
- entrypoint.root.listFiles(useGitIgnore: true),
- unorderedEquals([
- p.join(root, 'pubspec.yaml'),
- p.join(root, '.gitignore'),
- p.join(root, 'file2.text'),
- p.join(root, 'subdir', 'subfile2.text')
- ]));
-
- expect(
- entrypoint.root.listFiles(),
- unorderedEquals([
- p.join(root, 'pubspec.yaml'),
- p.join(root, 'file1.txt'),
- p.join(root, 'file2.text'),
- p.join(root, 'subdir', 'subfile1.txt'),
- p.join(root, 'subdir', 'subfile2.text')
- ]));
+ expect(entrypoint.root.listFiles(), {
+ p.join(root, 'pubspec.yaml'),
+ p.join(root, 'file2.text'),
+ p.join(root, 'subdir', 'subfile2.text')
+ });
});
group('with a submodule', () {
@@ -159,29 +258,145 @@
createEntrypoint();
});
- test('ignores its .gitignore without useGitIgnore', () {
- expect(
- entrypoint.root.listFiles(),
- unorderedEquals([
- p.join(root, 'pubspec.yaml'),
- p.join(root, 'submodule', 'file1.txt'),
- p.join(root, 'submodule', 'file2.text'),
- ]));
- });
-
test('respects its .gitignore with useGitIgnore', () {
- expect(
- entrypoint.root.listFiles(useGitIgnore: true),
- unorderedEquals([
- p.join(root, '.gitmodules'),
- p.join(root, 'pubspec.yaml'),
- p.join(root, 'submodule', '.gitignore'),
- p.join(root, 'submodule', 'file2.text'),
- ]));
+ expect(entrypoint.root.listFiles(), {
+ p.join(root, 'pubspec.yaml'),
+ p.join(root, 'submodule', 'file2.text'),
+ });
});
});
- commonTests();
+ test('ignores pubspec.lock files', () async {
+ await d.dir(appPath, [
+ d.file('pubspec.lock'),
+ d.dir('subdir', [d.file('pubspec.lock')])
+ ]).create();
+
+ expect(entrypoint.root.listFiles(), {p.join(root, 'pubspec.yaml')});
+ });
+
+ test('ignores packages directories', () async {
+ await d.dir(appPath, [
+ d.dir('packages', [d.file('file.txt', 'contents')]),
+ d.dir('subdir', [
+ d.dir('packages', [d.file('subfile.txt', 'subcontents')]),
+ ])
+ ]).create();
+
+ expect(entrypoint.root.listFiles(), {p.join(root, 'pubspec.yaml')});
+ });
+
+ test('allows pubspec.lock directories', () async {
+ await d.dir(appPath, [
+ d.dir('pubspec.lock', [
+ d.file('file.txt', 'contents'),
+ ])
+ ]).create();
+
+ expect(entrypoint.root.listFiles(), {
+ p.join(root, 'pubspec.yaml'),
+ p.join(root, 'pubspec.lock', 'file.txt')
+ });
+ });
+
+ group('and "beneath"', () {
+ test('only lists files beneath the given root', () async {
+ await d.dir(appPath, [
+ d.file('file1.txt', 'contents'),
+ d.file('file2.txt', 'contents'),
+ d.dir('subdir', [
+ d.file('subfile1.txt', 'subcontents'),
+ d.file('subfile2.txt', 'subcontents'),
+ d.dir('subsubdir', [
+ d.file('subsubfile1.txt', 'subsubcontents'),
+ d.file('subsubfile2.txt', 'subsubcontents'),
+ ])
+ ])
+ ]).create();
+
+ expect(entrypoint.root.listFiles(beneath: 'subdir'), {
+ p.join(root, 'subdir', 'subfile1.txt'),
+ p.join(root, 'subdir', 'subfile2.txt'),
+ p.join(root, 'subdir', 'subsubdir', 'subsubfile1.txt'),
+ p.join(root, 'subdir', 'subsubdir', 'subsubfile2.txt')
+ });
+ });
+ });
+
+ test('.pubignore', () async {
+ await d.validPackage.create();
+ await d.dir(appPath, [
+ d.file('.pubignore', '''
+/lib/ignored.dart
+'''),
+ d.dir('lib', [d.file('ignored.dart', 'content')]),
+ d.dir('lib', [d.file('not_ignored.dart', 'content')]),
+ ]).create();
+ createEntrypoint();
+ expect(entrypoint.root.listFiles(), {
+ p.join(root, 'LICENSE'),
+ p.join(root, 'CHANGELOG.md'),
+ p.join(root, 'README.md'),
+ p.join(root, 'pubspec.yaml'),
+ p.join(root, 'lib', 'test_pkg.dart'),
+ p.join(root, 'lib', 'not_ignored.dart'),
+ });
+ });
+ });
+
+ test('.pubignore overrides .gitignore', () async {
+ ensureGit();
+ final repo = d.git(appPath, [
+ d.appPubspec(),
+ d.file('.gitignore', '*.txt'),
+ d.file('.pubignore', '*.text'),
+ d.file('ignored_by_pubignore.text', ''),
+ d.file('not_ignored_by_gitignore.txt', 'contents'),
+ d.file('.hidden'),
+ d.dir('gitignoredir', [
+ d.file('.gitignore', 'foo'),
+ d.file('foo'),
+ d.file('bar'),
+ d.file('a.txt'),
+ d.file('a.text'),
+ d.dir('nested', [
+ d.file('.pubignore', '''
+!foo
+!*.text
+'''),
+ d.file('foo'),
+ d.file('bar'),
+ d.file('c.text'),
+ ]),
+ ]),
+ d.dir('pubignoredir', [
+ d.file('.pubignore', 'bar'),
+ d.file('foo'),
+ d.file('bar'),
+ d.file('b.txt'),
+ d.file('b.text'),
+ ]),
+ ]);
+ await repo.create();
+ createEntrypoint();
+ await d.dir(appPath, [
+ d.file('ignored_by_gitignore.txt', 'contents'),
+ d.file('ignored_by_pubignore2.text', ''),
+ ]).create();
+
+ createEntrypoint();
+ expect(entrypoint.root.listFiles(), {
+ p.join(root, 'pubspec.yaml'),
+ p.join(root, 'not_ignored_by_gitignore.txt'),
+ p.join(root, 'ignored_by_gitignore.txt'),
+ p.join(root, 'gitignoredir', 'bar'),
+ p.join(root, 'gitignoredir', 'a.txt'),
+ p.join(root, 'gitignoredir', 'nested', 'foo'),
+ p.join(root, 'gitignoredir', 'nested', 'bar'),
+ p.join(root, 'gitignoredir', 'nested', 'c.text'),
+ p.join(root, 'pubignoredir', 'foo'),
+ p.join(root, 'pubignoredir', 'b.txt'),
+ });
});
}
@@ -194,99 +409,3 @@
entrypoint = null;
});
}
-
-void commonTests() {
- test('ignores broken symlinks', () async {
- // Windows requires us to symlink to a directory that actually exists.
- await d.dir(appPath, [d.dir('target')]).create();
- symlinkInSandbox(p.join(appPath, 'target'), p.join(appPath, 'link'));
- deleteEntry(p.join(d.sandbox, appPath, 'target'));
-
- expect(entrypoint.root.listFiles(), equals([p.join(root, 'pubspec.yaml')]));
- });
-
- test('ignores pubspec.lock files', () async {
- await d.dir(appPath, [
- d.file('pubspec.lock'),
- d.dir('subdir', [d.file('pubspec.lock')])
- ]).create();
-
- expect(entrypoint.root.listFiles(), equals([p.join(root, 'pubspec.yaml')]));
- });
-
- test('ignores packages directories', () async {
- await d.dir(appPath, [
- d.dir('packages', [d.file('file.txt', 'contents')]),
- d.dir('subdir', [
- d.dir('packages', [d.file('subfile.txt', 'subcontents')]),
- ])
- ]).create();
-
- expect(entrypoint.root.listFiles(), equals([p.join(root, 'pubspec.yaml')]));
- });
-
- test('allows pubspec.lock directories', () async {
- await d.dir(appPath, [
- d.dir('pubspec.lock', [
- d.file('file.txt', 'contents'),
- ])
- ]).create();
-
- expect(
- entrypoint.root.listFiles(),
- unorderedEquals([
- p.join(root, 'pubspec.yaml'),
- p.join(root, 'pubspec.lock', 'file.txt')
- ]));
- });
-
- group('and "beneath"', () {
- test('only lists files beneath the given root', () async {
- await d.dir(appPath, [
- d.file('file1.txt', 'contents'),
- d.file('file2.txt', 'contents'),
- d.dir('subdir', [
- d.file('subfile1.txt', 'subcontents'),
- d.file('subfile2.txt', 'subcontents'),
- d.dir('subsubdir', [
- d.file('subsubfile1.txt', 'subsubcontents'),
- d.file('subsubfile2.txt', 'subsubcontents'),
- ])
- ])
- ]).create();
-
- expect(
- entrypoint.root.listFiles(beneath: p.join(root, 'subdir')),
- unorderedEquals([
- p.join(root, 'subdir', 'subfile1.txt'),
- p.join(root, 'subdir', 'subfile2.txt'),
- p.join(root, 'subdir', 'subsubdir', 'subsubfile1.txt'),
- p.join(root, 'subdir', 'subsubdir', 'subsubfile2.txt')
- ]));
- });
-
- test("doesn't care if the root is disallowed", () async {
- await d.dir(appPath, [
- d.file('file1.txt', 'contents'),
- d.file('file2.txt', 'contents'),
- d.dir('packages', [
- d.file('subfile1.txt', 'subcontents'),
- d.file('subfile2.txt', 'subcontents'),
- d.dir('subsubdir', [
- d.file('subsubfile1.txt', 'subsubcontents'),
- d.file('subsubfile2.txt', 'subsubcontents')
- ])
- ])
- ]).create();
-
- expect(
- entrypoint.root.listFiles(beneath: p.join(root, 'packages')),
- unorderedEquals([
- p.join(root, 'packages', 'subfile1.txt'),
- p.join(root, 'packages', 'subfile2.txt'),
- p.join(root, 'packages', 'subsubdir', 'subsubfile1.txt'),
- p.join(root, 'packages', 'subsubdir', 'subsubfile2.txt')
- ]));
- });
- });
-}
diff --git a/test/validator/compiled_dartdoc_test.dart b/test/validator/compiled_dartdoc_test.dart
index 495f72f..6d758b4 100644
--- a/test/validator/compiled_dartdoc_test.dart
+++ b/test/validator/compiled_dartdoc_test.dart
@@ -79,23 +79,5 @@
await expectValidation(compiledDartdoc, warnings: isNotEmpty);
});
-
- test(
- 'contains compiled dartdoc in a non-gitignored hidden '
- 'directory', () async {
- ensureGit();
-
- await d.git(appPath, [
- d.dir('.doc-out', [
- d.file('nav.json', ''),
- d.file('index.html', ''),
- d.file('styles.css', ''),
- d.file('dart-logo-small.png', ''),
- d.file('client-live-nav.js', '')
- ])
- ]).create();
-
- await expectValidation(compiledDartdoc, warnings: isNotEmpty);
- });
});
}
diff --git a/test/validator/gitignore_test.dart b/test/validator/gitignore_test.dart
new file mode 100644
index 0000000..d3b32a0
--- /dev/null
+++ b/test/validator/gitignore_test.dart
@@ -0,0 +1,47 @@
+// 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';
+
+Future<void> expectValidation(error, int exitCode) async {
+ await runPub(
+ error: error,
+ args: ['publish', '--dry-run'],
+ environment: {'_PUB_TEST_SDK_VERSION': '2.12.0'},
+ workingDirectory: d.path(appPath),
+ exitCode: exitCode,
+ );
+}
+
+void main() {
+ test(
+ 'should consider a package valid if it contains no checked in otherwise ignored files',
+ () async {
+ await d.git('myapp', [
+ ...d.validPackage.contents,
+ d.file('foo.txt'),
+ ]).create();
+
+ await pubGet(environment: {'_PUB_TEST_SDK_VERSION': '1.12.0'});
+
+ await expectValidation(contains('Package has 0 warnings.'), 0);
+
+ await d.dir('myapp', [
+ d.file('.gitignore', '*.txt'),
+ ]).create();
+
+ await expectValidation(
+ allOf([
+ contains('Package has 1 warning.'),
+ contains('foo.txt'),
+ contains(
+ 'Consider adjusting your `.gitignore` files to not ignore those files'),
+ ]),
+ exit_codes.DATA);
+ });
+}