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);
+  });
+}