Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lib/src/command/cache.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import '../command.dart';
import 'cache_add.dart';
import 'cache_clean.dart';
import 'cache_gc.dart';
import 'cache_list.dart';
import 'cache_preload.dart';
import 'cache_repair.dart';
Expand All @@ -24,5 +25,6 @@ class CacheCommand extends PubCommand {
addSubcommand(CacheCleanCommand());
addSubcommand(CacheRepairCommand());
addSubcommand(CachePreloadCommand());
addSubcommand(CacheGcCommand());
}
}
139 changes: 139 additions & 0 deletions lib/src/command/cache_gc.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// 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 =
runningFromTest ? const Duration(seconds: 2) : const Duration(hours: 2);

CacheGcCommand() {
argParser.addFlag(
'force',
abbr: 'f',
help: 'Prune cache without confirmation',
);
}

@override
Future<void> runProtected() async {
final activeRoots = cache.activeRoots();
final validActiveRoots = <String>[];
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 = 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;
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');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe restrict the number of outputs here?

}
}
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 will need to run `$topLevelProgram pub get` again to work correctly.''',
);
log.message('Will recover ${readableFileSize(sum)}.');

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);
}
});
}
}
}
14 changes: 1 addition & 13 deletions lib/src/command/lish.dart
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ the \$PUB_HOSTED_URL environment variable.''');
baseDir: entrypoint.workPackage.dir,
).toBytes();

final size = _readableFileSize(packageBytes.length);
final size = readableFileSize(packageBytes.length);
log.message('\nTotal compressed archive size: $size.\n');

final validationResult =
Expand Down Expand Up @@ -526,18 +526,6 @@ the \$PUB_HOSTED_URL environment variable.''');
}
}

String _readableFileSize(int size) {
if (size >= 1 << 30) {
return '${size ~/ (1 << 30)} GB';
} else if (size >= 1 << 20) {
return '${size ~/ (1 << 20)} MB';
} else if (size >= 1 << 10) {
return '${size ~/ (1 << 10)} KB';
} else {
return '<1 KB';
}
}

class _Publication {
Uint8List packageBytes;
int warningCount;
Expand Down
8 changes: 8 additions & 0 deletions lib/src/source/cached.dart
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@ abstract class CachedSource extends Source {
/// Returns a list of results indicating for each if that package was
/// successfully repaired.
Future<Iterable<RepairResult>> repairCachedPackages(SystemCache cache);

/// Return all files directories inside this source that can be removed while
/// preserving the packages given by [alivePackages] a list of package root
/// directories. They should all be canonicalized.
Future<List<String>> entriesToGc(
SystemCache cache,
Set<String> alivePackages,
);
}

/// The result of repairing a single cache entry.
Expand Down
68 changes: 68 additions & 0 deletions lib/src/source/git.dart
Original file line number Diff line number Diff line change
Expand Up @@ -900,6 +900,74 @@ class GitSource extends CachedSource {
}
return name;
}

@override
Future<List<String>> entriesToGc(
SystemCache cache,
Set<String> alivePackages,
) async {
final rootDir = p.canonicalize(cache.rootDirForSource(this));
if (!entryExists(rootDir)) return const [];

final gitDirsToRemove = <String>{};
// First enumerate all git repos inside [rootDir].
for (final entry in listDir(rootDir)) {
final gitEntry = p.join(entry, '.git');
if (!entryExists(gitEntry)) continue;
gitDirsToRemove.add(p.canonicalize(entry));
}
final cacheDirsToRemove = <String>{};
try {
cacheDirsToRemove.addAll(
listDir(p.join(rootDir, 'cache')).map(p.canonicalize),
);
} on IOException {
// Most likely the directory didn't exist.
// ignore.
}
// For each package walk up parent directories to find the containing git
// repo, and mark it alive by removing from `gitDirsToRemove`.
for (final alivePackage in alivePackages) {
var candidate = p.canonicalize(alivePackage);
while (!p.equals(candidate, rootDir)) {
if (gitDirsToRemove.remove(candidate)) {
// Package is alive, now also retain its cachedir.
//
// TODO(sigurdm): Should we just GC all cache-dirs? They are not
// needed for consuming packages, and most likely will be recreated
// when needed.
final gitEntry = p.join(candidate, '.git');
try {
if (dirExists(gitEntry)) {
final path =
(await git.run([
'remote',
'get-url',
'origin',
], workingDir: candidate)).split('\n').first;
cacheDirsToRemove.remove(p.canonicalize(path));
} else if (fileExists(gitEntry)) {
// Potential future - using worktrees.
final path =
(await git.run([
'worktree',
'list',
'--porcelain',
], workingDir: candidate)).split('\n').first.split(' ').last;
cacheDirsToRemove.remove(p.canonicalize(path));
}
} on git.GitException catch (e) {
log.fine('Failed to find canonical cache for $candidate, $e');
}
break;
}
// Try the parent directory
candidate = p.dirname(candidate);
}
}

return [...cacheDirsToRemove, ...gitDirsToRemove];
}
}

class GitDescription extends Description {
Expand Down
56 changes: 56 additions & 0 deletions lib/src/source/hosted.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1761,6 +1761,62 @@ See $contentHashesDocumentationUrl.
}
}

@override
Future<List<String>> entriesToGc(
SystemCache cache,
Set<String> alivePackages,
) async {
final root = p.canonicalize(cache.rootDirForSource(this));
final result = <String>{};
final List<String> hostDirs;

try {
hostDirs = listDir(root);
} on IOException {
// Hosted cache seems uninitialized. GC nothing.
return [];
}
for (final hostDir in hostDirs) {
final List<String> packageDirs;
try {
packageDirs = listDir(hostDir).map(p.canonicalize).toList();
} on IOException {
// Failed to list `hostDir`. Perhaps a stray file? Skip.
continue;
}
for (final packageDir in packageDirs) {
if (!alivePackages.contains(packageDir)) {
result.add(packageDir);
// Also clear the associated hash file.
final hashFile = p.join(
cache.rootDir,
'hosted-hashes',
p.basename(hostDir),
'${p.basename(packageDir)}.sha256',
);
if (fileExists(hashFile)) {
result.add(hashFile);
}
}
}
// Clear all version listings older than two days, they'd likely need to
// be re-fetched anyways:
for (final cacheFile in listDir(
p.join(hostDir, _versionListingDirectory),
)) {
final stat = tryStatFile(cacheFile);

if (stat != null &&
DateTime.now().difference(stat.modified) >
const Duration(days: 2)) {
result.add(cacheFile);
}
}
}

return result.toList();
}

/// Enables speculative prefetching of dependencies of packages queried with
/// [doGetVersions].
Future<T> withPrefetching<T>(Future<T> Function() callback) async {
Expand Down
12 changes: 12 additions & 0 deletions lib/src/utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -825,3 +825,15 @@ extension ExpectEntries on YamlList {
),
];
}

String readableFileSize(int size) {
if (size >= 1 << 30) {
return '${size ~/ (1 << 30)} GB';
} else if (size >= 1 << 20) {
return '${size ~/ (1 << 20)} MB';
} else if (size >= 1 << 10) {
return '${size ~/ (1 << 10)} KB';
} else {
return '<1 KB';
}
}
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ dependencies:
http_parser: ^4.1.2
meta: ^1.17.0
path: ^1.9.1
pool: ^1.5.1
pool: ^1.0.0
pub_semver: ^2.2.0
shelf: ^1.4.2
source_span: ^1.10.1
Expand Down
Loading
Loading