blob: 088762535393bd1480304bc047bde6091d4bee49 [file] [log] [blame]
// Copyright (c) 2025, 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:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
import '../command.dart';
import '../command_runner.dart';
import '../io.dart';
import '../log.dart' as log;
import '../package_config.dart';
import '../utils.dart';
class CacheGcCommand extends PubCommand {
@override
String get name => 'gc';
@override
String get description => 'Prunes unused packages from the system cache.';
@override
bool get takesArguments => false;
final dontRemoveFilesOlderThan = const Duration(hours: 2);
CacheGcCommand() {
argParser.addFlag(
'force',
abbr: 'f',
help: 'Prune cache without confirmation',
hideNegatedUsage: true,
);
argParser.addFlag(
'collect-recent',
help: 'Also delete recent files',
hideNegatedUsage: true,
);
argParser.addFlag(
'dry-run',
help: 'Print list of files that would be deleted',
hideNegatedUsage: true,
);
}
@override
Future<void> runProtected() async {
final dryRun = argResults.flag('dry-run');
final activeRoots = cache.activeRoots();
// All the `activeRoots` that we could read and parse a
// .dart_tool/packageConfig.json from.
final validActiveRoots = <String>[];
// All the rootUri paths to cached packages included from
// `validActiveRoots`.
final paths = <String>{};
for (final packageConfigPath in activeRoots) {
late final PackageConfig packageConfig;
try {
packageConfig = PackageConfig.fromJson(
json.decode(readTextFile(packageConfigPath)),
);
} on IOException catch (e) {
// Failed to read file - probably got deleted.
log.fine('Failed to read packageConfig $packageConfigPath: $e');
continue;
} on FormatException catch (e) {
log.warning(
'Failed to decode packageConfig $packageConfigPath: $e.\n'
'It could be corrupted',
);
// Failed to decode - probably corrupted.
continue;
}
for (final package in packageConfig.packages) {
final rootUri = p.canonicalize(
package.resolvedRootDir(packageConfigPath),
);
if (p.isWithin(cache.rootDir, rootUri)) {
paths.add(rootUri);
}
}
validActiveRoots.add(packageConfigPath);
}
final now = DateTime.now();
final allPathsToGC =
[
for (final source in cache.cachedSources)
...await source.entriesToGc(
cache,
paths
.where(
(path) => p.isWithin(
p.canonicalize(cache.rootDirForSource(source)),
path,
),
)
.toSet(),
),
].where((path) {
// Only clear cache entries older than 2 hours to avoid race
// conditions with ongoing `pub get` processes.
final s = statPath(path);
if (s.type == FileSystemEntityType.notFound) return false;
if (argResults.flag('collect-recent')) return true;
return now.difference(s.modified) > dontRemoveFilesOlderThan;
}).toList();
if (validActiveRoots.isEmpty) {
log.message('Found no active projects.');
} else {
final s = validActiveRoots.length == 1 ? '' : 's';
log.message('Found ${validActiveRoots.length} active project$s:');
for (final packageConfigPath in validActiveRoots) {
final parts = p.split(packageConfigPath);
var projectDir = packageConfigPath;
if (parts[parts.length - 2] == '.dart_tool' &&
parts[parts.length - 1] == 'package_config.json') {
projectDir = p.joinAll(parts.sublist(0, parts.length - 2));
}
log.message('* $projectDir');
}
}
var sum = 0;
for (final entry in allPathsToGC) {
if (dirExists(entry)) {
for (final file in listDir(
entry,
recursive: true,
includeHidden: true,
includeDirs: false,
)) {
sum += tryStatFile(file)?.size ?? 0;
}
} else {
sum += tryStatFile(entry)?.size ?? 0;
}
}
if (sum == 0) {
log.message('No unused cache entries found.');
return;
}
log.message('');
log.message(
'''
All other projects ${dryRun ? 'would' : 'will'} need to run `$topLevelProgram pub get` again to work correctly.''',
);
log.message(
'${dryRun ? 'Would' : 'Will'} recover ${readableFileSize(sum)}.',
);
if (dryRun) {
log.message('Would delete:');
for (final path in allPathsToGC..sort()) {
log.message(path);
}
} else if (argResults.flag('force') ||
await confirm('Are you sure you want to continue?')) {
await log.progress('Deleting unused cache entries', () async {
for (final path in allPathsToGC..sort()) {
tryDeleteEntry(path);
}
});
}
}
}