Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
/// Return all files directories inside this source that can be removed while
/// Return all 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