Make gitignore validator use gitignores from repo-root and down. (#3169)

diff --git a/lib/src/git.dart b/lib/src/git.dart
index 0633169..5fa7536 100644
--- a/lib/src/git.dart
+++ b/lib/src/git.dart
@@ -5,6 +5,8 @@
 /// Helper functionality for invoking Git.
 import 'dart:async';
 
+import 'package:path/path.dart' as p;
+
 import 'exceptions.dart';
 import 'io.dart';
 import 'log.dart' as log;
@@ -103,6 +105,22 @@
 
 String? _commandCache;
 
+/// Returns the root of the git repo [dir] belongs to. Returns `null` if not
+/// in a git repo or git is not installed.
+String? repoRoot(String dir) {
+  if (isInstalled) {
+    try {
+      return p.normalize(
+        runSync(['rev-parse', '--show-toplevel'], workingDir: dir).first,
+      );
+    } on GitException {
+      // Not in a git folder.
+      return null;
+    }
+  }
+  return null;
+}
+
 /// Checks whether [command] is the Git command for this computer.
 bool _tryGitCommand(String command) {
   // If "git --version" prints something familiar, git is working.
diff --git a/lib/src/package.dart b/lib/src/package.dart
index 5c46709..1aeb9dc 100644
--- a/lib/src/package.dart
+++ b/lib/src/package.dart
@@ -224,16 +224,7 @@
     // An in-memory package has no files.
     if (dir == null) return [];
 
-    var root = dir;
-    if (git.isInstalled) {
-      try {
-        root = p.normalize(
-          git.runSync(['rev-parse', '--show-toplevel'], workingDir: dir).first,
-        );
-      } on git.GitException {
-        // Not in a git folder.
-      }
-    }
+    var root = git.repoRoot(dir) ?? dir;
     beneath = p
         .toUri(p.normalize(p.relative(p.join(dir, beneath ?? '.'), from: root)))
         .path;
diff --git a/lib/src/validator/gitignore.dart b/lib/src/validator/gitignore.dart
index df01214..0986fed 100644
--- a/lib/src/validator/gitignore.dart
+++ b/lib/src/validator/gitignore.dart
@@ -31,18 +31,25 @@
         '--exclude-standard',
         '--recurse-submodules'
       ], workingDir: entrypoint.root.dir);
+      final root = git.repoRoot(entrypoint.root.dir) ?? entrypoint.root.dir;
+      var beneath = p.posix.joinAll(
+          p.split(p.normalize(p.relative(entrypoint.root.dir, from: root))));
+      if (beneath == './') {
+        beneath = '';
+      }
       String resolve(String path) {
         if (Platform.isWindows) {
-          return p.joinAll([entrypoint.root.dir, ...p.posix.split(path)]);
+          return p.joinAll([root, ...p.posix.split(path)]);
         }
-        return p.join(entrypoint.root.dir, path);
+        return p.join(root, path);
       }
 
       final unignoredByGitignore = Ignore.listFiles(
+        beneath: beneath,
         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))));
+          return contents.map((entity) =>
+              p.posix.joinAll(p.split(p.relative(entity.path, from: root))));
         },
         ignoreForDir: (dir) {
           final gitIgnore = resolve('$dir/.gitignore');
@@ -52,8 +59,12 @@
           return rules.isEmpty ? null : Ignore(rules);
         },
         isDir: (dir) => dirExists(resolve(dir)),
-      ).toSet();
-
+      ).map((file) {
+        final relative = p.relative(resolve(file), from: entrypoint.root.dir);
+        return Platform.isWindows
+            ? p.posix.joinAll(p.split(relative))
+            : relative;
+      }).toSet();
       final ignoredFilesCheckedIn = checkedIntoGit
           .where((file) => !unignoredByGitignore.contains(file))
           .toList();
diff --git a/test/validator/gitignore_test.dart b/test/validator/gitignore_test.dart
index d6140f5..06d3be4 100644
--- a/test/validator/gitignore_test.dart
+++ b/test/validator/gitignore_test.dart
@@ -4,18 +4,23 @@
 
 // @dart=2.10
 
+import 'package:path/path.dart' as p;
 import 'package:pub/src/exit_codes.dart' as exit_codes;
 import 'package:test/test.dart';
 
 import '../descriptor.dart' as d;
 import '../test_pub.dart';
 
-Future<void> expectValidation(error, int exitCode) async {
+Future<void> expectValidation(
+  error,
+  int exitCode, {
+  String workingDirectory,
+}) async {
   await runPub(
     error: error,
     args: ['publish', '--dry-run'],
     environment: {'_PUB_TEST_SDK_VERSION': '2.12.0'},
-    workingDirectory: d.path(appPath),
+    workingDirectory: workingDirectory ?? d.path(appPath),
     exitCode: exitCode,
   );
 }
@@ -46,4 +51,37 @@
         ]),
         exit_codes.DATA);
   });
+
+  test('Should also consider gitignores from above the package root', () async {
+    await d.git('reporoot', [
+      d.dir(
+        'myapp',
+        [
+          d.file('foo.txt'),
+          ...d.validPackage.contents,
+        ],
+      ),
+    ]).create();
+    final packageRoot = p.join(d.sandbox, 'reporoot', 'myapp');
+    await pubGet(
+        environment: {'_PUB_TEST_SDK_VERSION': '1.12.0'},
+        workingDirectory: packageRoot);
+
+    await expectValidation(contains('Package has 0 warnings.'), 0,
+        workingDirectory: packageRoot);
+
+    await d.dir('reporoot', [
+      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,
+        workingDirectory: packageRoot);
+  });
 }