Add a README.md to the pub cache after command ends (#3650)

diff --git a/lib/src/command/cache_clean.dart b/lib/src/command/cache_clean.dart
index 42b0f1f..786707d 100644
--- a/lib/src/command/cache_clean.dart
+++ b/lib/src/command/cache_clean.dart
@@ -32,8 +32,7 @@
 You will have to run `$topLevelProgram pub get` again in each project.
 Are you sure?''')) {
         log.message('Removing pub cache directory ${cache.rootDir}.');
-        deleteEntry(cache.rootDir);
-        ensureDir(cache.rootDir);
+        cache.clean();
       }
     } else {
       log.message('No pub cache at ${cache.rootDir}.');
diff --git a/lib/src/source/cached.dart b/lib/src/source/cached.dart
index 54630c7..f847c87 100644
--- a/lib/src/source/cached.dart
+++ b/lib/src/source/cached.dart
@@ -55,7 +55,10 @@
       dirExists(getDirectoryInCache(id, cache));
 
   /// Downloads the package identified by [id] to the system cache.
-  Future<PackageId> downloadToSystemCache(PackageId id, SystemCache cache);
+  Future<DownloadPackageResult> downloadToSystemCache(
+    PackageId id,
+    SystemCache cache,
+  );
 
   /// Returns the [Package]s that have been downloaded to the system cache.
   List<Package> getCachedPackages(SystemCache cache);
@@ -86,3 +89,14 @@
     required this.success,
   });
 }
+
+class DownloadPackageResult {
+  /// The resolved package.
+  final PackageId packageId;
+
+  /// Whether we had to make changes in the cache in order to download the
+  /// package.
+  final bool didUpdate;
+
+  DownloadPackageResult(this.packageId, {required this.didUpdate});
+}
diff --git a/lib/src/source/git.dart b/lib/src/source/git.dart
index 24b1294..4bef7fa 100644
--- a/lib/src/source/git.dart
+++ b/lib/src/source/git.dart
@@ -298,11 +298,12 @@
   /// itself; each of the commit-specific directories are clones of a directory
   /// in `cache/`.
   @override
-  Future<PackageId> downloadToSystemCache(
+  Future<DownloadPackageResult> downloadToSystemCache(
     PackageId id,
     SystemCache cache,
   ) async {
     return await _pool.withResource(() async {
+      bool didUpdate = false;
       final ref = id.toRef();
       final description = ref.description;
       if (description is! GitDescription) {
@@ -317,7 +318,7 @@
       final resolvedRef =
           (id.description as GitResolvedDescription).resolvedRef;
 
-      await _ensureRevision(ref, resolvedRef, cache);
+      didUpdate |= await _ensureRevision(ref, resolvedRef, cache);
 
       var revisionCachePath = _revisionCachePath(id, cache);
       final path = description.path;
@@ -326,11 +327,12 @@
           await _clone(_repoCachePath(ref, cache), revisionCachePath);
           await _checkOut(revisionCachePath, resolvedRef);
           _writePackageList(revisionCachePath, [path]);
+          didUpdate = true;
         } else {
-          _updatePackageList(revisionCachePath, path);
+          didUpdate |= _updatePackageList(revisionCachePath, path);
         }
       });
-      return id;
+      return DownloadPackageResult(id, didUpdate: didUpdate);
     });
   }
 
@@ -425,13 +427,15 @@
 
   /// Ensures that the canonical clone of the repository referred to by [ref]
   /// contains the given Git [revision].
-  Future _ensureRevision(
+  ///
+  /// Returns `true` if it had to update anything.
+  Future<bool> _ensureRevision(
     PackageRef ref,
     String revision,
     SystemCache cache,
   ) async {
     var path = _repoCachePath(ref, cache);
-    if (_updatedRepos.contains(path)) return;
+    if (_updatedRepos.contains(path)) return false;
 
     await _deleteGitRepoIfInvalid(path);
 
@@ -443,28 +447,33 @@
       await _firstRevision(path, revision);
     } on git.GitException catch (_) {
       await _updateRepoCache(ref, cache);
+      return true;
     }
+    return false;
   }
 
   /// Ensures that the canonical clone of the repository referred to by [ref]
   /// exists and is up-to-date.
-  Future _ensureRepoCache(PackageRef ref, SystemCache cache) async {
+  ///
+  /// Returns `true` if it had to update anything.
+  Future<bool> _ensureRepoCache(PackageRef ref, SystemCache cache) async {
     var path = _repoCachePath(ref, cache);
-    if (_updatedRepos.contains(path)) return;
+    if (_updatedRepos.contains(path)) return false;
 
     await _deleteGitRepoIfInvalid(path);
 
     if (!entryExists(path)) {
       await _createRepoCache(ref, cache);
+      return true;
     } else {
-      await _updateRepoCache(ref, cache);
+      return await _updateRepoCache(ref, cache);
     }
   }
 
   /// Creates the canonical clone of the repository referred to by [ref].
   ///
   /// This assumes that the canonical clone doesn't yet exist.
-  Future _createRepoCache(PackageRef ref, SystemCache cache) async {
+  Future<void> _createRepoCache(PackageRef ref, SystemCache cache) async {
     final description = ref.description;
     if (description is! GitDescription) {
       throw ArgumentError('Wrong source');
@@ -484,14 +493,17 @@
   /// [ref].
   ///
   /// This assumes that the canonical clone already exists.
-  Future _updateRepoCache(
+  ///
+  /// Returns `true` if it had to update anything.
+  Future<bool> _updateRepoCache(
     PackageRef ref,
     SystemCache cache,
   ) async {
     var path = _repoCachePath(ref, cache);
-    if (_updatedRepos.contains(path)) return Future.value();
+    if (_updatedRepos.contains(path)) return false;
     await git.run(['fetch'], workingDir: path);
     _updatedRepos.add(path);
+    return true;
   }
 
   /// Clean-up [dirPath] if it's an invalid git repository.
@@ -523,11 +535,14 @@
 
   /// Updates the package list file in [revisionCachePath] to include [path], if
   /// necessary.
-  void _updatePackageList(String revisionCachePath, String path) {
+  ///
+  /// Returns `true` if it had to update anything.
+  bool _updatePackageList(String revisionCachePath, String path) {
     var packages = _readPackageList(revisionCachePath);
-    if (packages.contains(path)) return;
+    if (packages.contains(path)) return false;
 
     _writePackageList(revisionCachePath, packages..add(path));
+    return true;
   }
 
   /// Returns the list of packages in [revisionCachePath].
@@ -571,7 +586,7 @@
   ///
   /// If [shallow] is true, creates a shallow clone that contains no history
   /// for the repository.
-  Future _clone(
+  Future<void> _clone(
     String from,
     String to, {
     bool mirror = false,
@@ -594,7 +609,7 @@
   }
 
   /// Checks out the reference [ref] in [repoPath].
-  Future _checkOut(String repoPath, String ref) {
+  Future<void> _checkOut(String repoPath, String ref) {
     return git
         .run(['checkout', ref], workingDir: repoPath).then((result) => null);
   }
diff --git a/lib/src/source/hosted.dart b/lib/src/source/hosted.dart
index 1cb05d6..2d481e4 100644
--- a/lib/src/source/hosted.dart
+++ b/lib/src/source/hosted.dart
@@ -728,8 +728,9 @@
   /// `pub get` with a filled cache to be a fast case that doesn't require any
   /// new version-listings.
   @override
-  Future<PackageId> downloadToSystemCache(
+  Future<DownloadPackageResult> downloadToSystemCache(
       PackageId id, SystemCache cache) async {
+    var didUpdate = false;
     final packageDir = getDirectoryInCache(id, cache);
 
     // Use the content-hash from the version-info to compare with what we
@@ -779,17 +780,20 @@
     if (dirExists(packageDir)) {
       contentHash ??= sha256FromCache(id, cache);
     } else {
+      didUpdate = true;
       if (cache.isOffline) {
         fail(
             'Missing package ${id.name}-${id.version}. Try again without --offline.');
       }
       contentHash = await _download(id, packageDir, cache);
     }
-    return PackageId(
-      id.name,
-      id.version,
-      (id.description as ResolvedHostedDescription).withSha256(contentHash),
-    );
+    return DownloadPackageResult(
+        PackageId(
+          id.name,
+          id.version,
+          (id.description as ResolvedHostedDescription).withSha256(contentHash),
+        ),
+        didUpdate: didUpdate);
   }
 
   /// Determines if the package identified by [id] is already downloaded to the
diff --git a/lib/src/system_cache.dart b/lib/src/system_cache.dart
index 11a10a1..2956626 100644
--- a/lib/src/system_cache.dart
+++ b/lib/src/system_cache.dart
@@ -227,10 +227,21 @@
   Future<PackageId> downloadPackage(PackageId id) async {
     final source = id.source;
     assert(source is CachedSource);
-    return await (source as CachedSource).downloadToSystemCache(
+    final result = await (source as CachedSource).downloadToSystemCache(
       id,
       this,
     );
+
+    // We only update the README.md in the cache when a change to the cache has
+    // happened. This is:
+    // * to avoid failing if used with a read-only cache, and
+    // * because the cost of writing a single file is negligible compared to
+    //   downloading a package, but might be significant in the fast-case where
+    //   a the cache is already valid.
+    if (result.didUpdate) {
+      _ensureReadme();
+    }
+    return result.packageId;
   }
 
   /// Get the latest version of [package].
@@ -270,6 +281,51 @@
 
     return latest;
   }
+
+  /// Removes all contents of the system cache.
+  ///
+  /// Rewrites the README.md.
+  void clean() {
+    deleteEntry(rootDir);
+    ensureDir(rootDir);
+    _ensureReadme();
+  }
+
+  /// Write a README.md file in the root of the cache directory to document the
+  /// contents of the folder.
+  ///
+  /// This should only be called when we are doing another operation that is
+  /// modifying the `PUB_CACHE`. This ensures that users won't experience
+  /// permission errors because we writing a `README.md` file, in a flow that
+  /// the user expected wouldn't have issues with a read-only `PUB_CACHE`.
+  void _ensureReadme() {
+    /// We only want to do this once per run.
+    if (_hasEnsuredReadme) return;
+    _hasEnsuredReadme = true;
+    final readmePath = p.join(rootDir, 'README.md');
+    try {
+      writeTextFile(readmePath, '''
+Pub Package Cache
+=================
+
+This folder is used by Pub to store cached packages used in Dart / Flutter
+projects.
+
+The contents of this folder should only be modified using the `dart pub` and
+`flutter pub` commands.
+
+Modifying this folder manually can lead to inconsistent behavior.
+
+For details on how manage the `PUB_CACHE`, see:
+https://dart.dev/go/pub-cache
+''');
+    } on Exception catch (e) {
+      // Failing to write the README.md should not disrupt other operations.
+      log.fine('Failed to write README.md in PUB_CACHE: $e');
+    }
+  }
+
+  bool _hasEnsuredReadme = false;
 }
 
 typedef SourceRegistry = Source Function(String? name);
diff --git a/test/cache/clean_test.dart b/test/cache/clean_test.dart
index 7a79bc8..b5909b5 100644
--- a/test/cache/clean_test.dart
+++ b/test/cache/clean_test.dart
@@ -23,11 +23,15 @@
     await d.appDir({'foo': 'any', 'bar': 'any'}).create();
     await pubGet();
     final cache = path.join(d.sandbox, cachePath);
-    expect(listDir(cache, includeHidden: true), isNotEmpty);
+    expect(listDir(cache, includeHidden: true), contains(endsWith('hosted')));
     await runPub(
         args: ['cache', 'clean', '--force'],
         output: 'Removing pub cache directory $cache.');
-    expect(listDir(cache, includeHidden: true), isEmpty);
+
+    expect(
+        listDir(cache, includeHidden: true),
+        // The README.md will be reconstructed.
+        [pathInCache('README.md')]);
   });
 
   test('running pub cache clean deletes cache only with confirmation',
@@ -38,7 +42,10 @@
     await d.appDir({'foo': 'any', 'bar': 'any'}).create();
     await pubGet();
     final cache = path.join(d.sandbox, cachePath);
-    expect(listDir(cache, includeHidden: true), isNotEmpty);
+    expect(
+      listDir(cache, includeHidden: true),
+      contains(pathInCache('hosted')),
+    );
     {
       final process = await startPub(
         args: ['cache', 'clean'],
@@ -46,7 +53,10 @@
       process.stdin.writeln('n');
       expect(await process.exitCode, 0);
     }
-    expect(listDir(cache, includeHidden: true), isNotEmpty);
+    expect(
+      listDir(cache, includeHidden: true),
+      contains(pathInCache('hosted')),
+    );
 
     {
       final process = await startPub(
@@ -55,6 +65,9 @@
       process.stdin.writeln('y');
       expect(await process.exitCode, 0);
     }
-    expect(listDir(cache, includeHidden: true), isEmpty);
+    expect(
+        listDir(cache,
+            includeHidden: true), // The README.md will be reconstructed.
+        [pathInCache('README.md')]);
   });
 }
diff --git a/test/cache/create_readme_test.dart b/test/cache/create_readme_test.dart
new file mode 100644
index 0000000..035fd68
--- /dev/null
+++ b/test/cache/create_readme_test.dart
@@ -0,0 +1,37 @@
+import 'dart:io';
+
+import 'package:test/test.dart';
+
+import '../descriptor.dart' as d;
+import '../test_pub.dart';
+
+void main() async {
+  test('PUB_CACHE/README.md gets created by command downloading to pub cache',
+      () async {
+    final server = await servePackages();
+    server.serve('foo', '1.0.0');
+    await d.appDir().create();
+    await pubGet();
+    await d.nothing(cachePath).validate();
+
+    await d.appDir({'foo': '1.0.0'}).create();
+    await pubGet();
+    await d.dir(cachePath, [
+      d.file('README.md', contains('https://dart.dev/go/pub-cache'))
+    ]).validate();
+    File(pathInCache('README.md')).deleteSync();
+    // No new download, so 'README.md' doesn't get updated.
+    await pubGet();
+    await d.dir(cachePath, [d.nothing('README.md')]).validate();
+  });
+
+  test('PUB_CACHE/README.md gets created by `dart pub cache clean`', () async {
+    final server = await servePackages();
+    server.serve('foo', '1.0.0');
+    await d.appDir({'foo': '1.0.0'}).create();
+    await pubGet();
+    await d.dir(cachePath, [
+      d.file('README.md', contains('https://dart.dev/go/pub-cache'))
+    ]).validate();
+  });
+}
diff --git a/test/embedding/embedding_test.dart b/test/embedding/embedding_test.dart
index 4419c15..89b4d08 100644
--- a/test/embedding/embedding_test.dart
+++ b/test/embedding/embedding_test.dart
@@ -99,7 +99,7 @@
       d.dir('bin', [
         d.file('main.dart', '''
 import 'dart:io';
-main() { 
+main() {
   print('Hi');
   exit(123);
 }
diff --git a/test/testdata/goldens/embedding/embedding_test/logfile is written with --verbose and on unexpected exceptions.txt b/test/testdata/goldens/embedding/embedding_test/logfile is written with --verbose and on unexpected exceptions.txt
index 219820e..f48d6ba 100644
--- a/test/testdata/goldens/embedding/embedding_test/logfile is written with --verbose and on unexpected exceptions.txt
+++ b/test/testdata/goldens/embedding/embedding_test/logfile is written with --verbose and on unexpected exceptions.txt
@@ -72,6 +72,21 @@
 [E] FINE: Extracted .tar.gz to $DIR
 [E] IO  : Renaming directory $A to $B
 [E] IO  : Deleting directory $DIR
+[E] IO  : Writing $N characters to text file $SANDBOX/cache/README.md.
+[E] FINE: Contents:
+[E]    | Pub Package Cache
+[E]    | =================
+[E]    | 
+[E]    | This folder is used by Pub to store cached packages used in Dart / Flutter
+[E]    | projects.
+[E]    | 
+[E]    | The contents of this folder should only be modified using the `dart pub` and
+[E]    | `flutter pub` commands.
+[E]    | 
+[E]    | Modifying this folder manually can lead to inconsistent behavior.
+[E]    | 
+[E]    | For details on how manage the `PUB_CACHE`, see:
+[E]    | https://dart.dev/go/pub-cache
 [E] IO  : Writing $N characters to text file pubspec.lock.
 [E] FINE: Contents:
 [E]    | # Generated by pub
@@ -216,6 +231,21 @@
 FINE: Extracted .tar.gz to $DIR
 IO  : Renaming directory $A to $B
 IO  : Deleting directory $DIR
+IO  : Writing $N characters to text file $SANDBOX/cache/README.md.
+FINE: Contents:
+   | Pub Package Cache
+   | =================
+   | 
+   | This folder is used by Pub to store cached packages used in Dart / Flutter
+   | projects.
+   | 
+   | The contents of this folder should only be modified using the `dart pub` and
+   | `flutter pub` commands.
+   | 
+   | Modifying this folder manually can lead to inconsistent behavior.
+   | 
+   | For details on how manage the `PUB_CACHE`, see:
+   | https://dart.dev/go/pub-cache
 MSG : + foo 1.0.0
 IO  : Writing $N characters to text file pubspec.lock.
 FINE: Contents: