fix(cleaner): 修复 Homebrew 清理执行与统计

This commit is contained in:
ysicing committed 2026-09-28 10:44:44 +08:00
1 parent a8e1170aac
commit f5ba9f88be
16 files changed
+253 -21

No files matched your search

+1 -1
View File
@@ -63,7 +63,7 @@ GitHub Actions 只在分支 push 且 `server/**` 发生变化时构建并发布
`XSTATS_APP_PROFILE`、`XSTATS_WIDGET_PROFILE` 指定 profile 名称后再签名构建。
若钥匙串里有多张同名证书,应确保所选证书包含在两个 profile 中;可用 `SIGN_ID` 指定对应证书的 SHA-1 指纹。
Homebrew 清理使用原生命令:扫描执行 `brew cleanup --prune=30 --dry-run`,确认后执行 `brew cleanup --prune=30`。候选路径和估算空间来自命令预览,释放空间来自执行输出;不会根据预览路径手动删除文件,也不会回退到扫描固定缓存目录。运行时禁用自动更新、自动移除依赖和分析上报,设置 60 秒超时、2 MB 输出上限并支持取消。Homebrew 缓存与日志排除在通用清理规则之外。
Homebrew 清理使用原生命令:扫描执行 `brew cleanup --prune=30 --dry-run`,确认后执行 `brew cleanup --prune=30`。候选路径和估算空间来自命令预览,释放空间来自执行输出;日志仅记录命令执行汇总,不把预览候选记为已删除。命令中途失败或取消时释放量显示为未确认。不会根据预览路径手动删除文件,也不会回退到扫描固定缓存目录。运行时禁用自动更新、自动移除依赖和分析上报。预览设置 60 秒超时和 2 MB 输出上限;实际清理持续读取输出并只保留有限的末尾诊断文本,不因输出量中断命令。两者都支持取消。Homebrew 缓存与日志排除在通用清理规则之外。
清理页顶栏的“项目产物”入口打开独立窗口;项目扫描独立于普通缓存规则,只在用户点击后运行。默认扫描目录来自 Mole 的默认项目位置、Agent 工作树及自动发现的项目目录;“扫描位置”显示当前完整列表,可新增、移除默认或自定义目录,并恢复默认。第一次修改后保存完整列表,被移除的默认目录不会在下次扫描时自动加入;恢复默认会重新发现默认目录。目录配置只保存在本机偏好中,不加入跨设备备份。每个根目录最多向下查找 6 层;最近 7 天有活动的条目会标记提醒。应用包及其内部目录不会被当作项目扫描。Git 跟踪内容、嵌套仓库、部署密钥、符号链接及未完成安全检查的条目不会作为可清理候选;用户逐项选择并确认后,每项移到废纸篓前重新检查 Git 跟踪状态与路径身份。云盘候选会提示同步删除风险。实现不依赖 Mole 命令,也不会在后台常驻扫描。
@@ -132,9 +132,8 @@ public enum CleanEngine {
report.failures += toolReport.failures
report.trashedBytes += toolReport.trashedBytes
report.wasCancelled = toolReport.wasCancelled
for item in scan.items {
log?.record(item: item, rule: scan.rule, action: "tool", detail: scan.rule.requiredTool)
}
report.hasUncertainFreedBytes = report.hasUncertainFreedBytes || toolReport.hasUncertainFreedBytes
log?.recordTool(rule: scan.rule, report: toolReport)
if report.wasCancelled { return report }
continue
}
@@ -146,13 +145,20 @@ public enum CleanEngine {
report.removedCount += 1
}
} catch {
if scan.rule.previewWithTool != nil {
report.hasUncertainFreedBytes = true
log?.recordToolInterruption(rule: scan.rule, error: error,
cancelled: error is CancellationError || Task.isCancelled)
}
if error is CancellationError || Task.isCancelled {
report.wasCancelled = true
return report
}
report.failures.append(tr("\(scan.rule.title):\(error.localizedDescription)"))
for item in scan.items {
log?.record(item: item, rule: scan.rule, action: "fail", detail: error.localizedDescription)
if scan.rule.previewWithTool == nil {
for item in scan.items {
log?.record(item: item, rule: scan.rule, action: "fail", detail: error.localizedDescription)
}
}
}
continue
@@ -211,6 +217,34 @@ public struct CleanLog: Sendable {
"bytes": bytes,
]
if let detail { entry["detail"] = detail }
write(entry)
}
/// 原生命令只报告汇总结果;扫描候选不能作为实际删除记录。
func recordTool(rule: CleanRule, report: CleanReport) {
var entry: [String: Any] = [
"time": ISO8601DateFormatter().string(from: Date()),
"rule": rule.id,
"action": "tool",
"bytes": report.freedBytes,
"removedCount": report.removedCount,
]
if let tool = rule.requiredTool { entry["detail"] = tool }
write(entry)
}
func recordToolInterruption(rule: CleanRule, error: Error, cancelled: Bool) {
var entry: [String: Any] = [
"time": ISO8601DateFormatter().string(from: Date()),
"rule": rule.id,
"action": cancelled ? "cancel" : "fail",
]
if let tool = rule.requiredTool { entry["tool"] = tool }
if !cancelled { entry["detail"] = error.localizedDescription }
write(entry)
}
private func write(_ entry: [String: Any]) {
guard var data = try? JSONSerialization.data(withJSONObject: entry, options: [.sortedKeys]) else { return }
data.append(0x0A)
@@ -143,6 +143,8 @@ public struct ToolCleanupPreview: Sendable {
public struct CleanReport: Sendable {
/// 取消是用户操作,保留已完成部分的统计,但不作为工具失败。
public var wasCancelled = false
/// 原生工具中途退出时,已释放空间可能未出现在报告中。
public var hasUncertainFreedBytes = false
public var freedBytes: UInt64 = 0
public var removedCount = 0
public var skippedCount = 0
@@ -25,16 +25,27 @@ enum HomebrewCleanup {
guard result.status == 0 else {
throw ToolExecutionError.failed(tool: "brew", status: result.status, output: String(result.output.prefix(2_000)))
}
if let report = result.cleanupReport { return report }
var report = CleanReport()
for line in result.output.components(separatedBy: .newlines) {
if line.hasPrefix("Removing: ") || line.hasPrefix("Pruning ") { report.removedCount += 1 }
if let bytes = try summarySize(line, prefix: "==> This operation has freed approximately ") {
report.freedBytes = bytes
}
try recordExecutionLine(line, report: &report)
}
return report
}
static func recordExecutionLine(_ line: String, report: inout CleanReport) throws {
if line.hasPrefix("Removing: ") {
report.removedCount += 1
} else if line.hasPrefix("Pruning ") {
// 格式为 "Pruning 57 files from: <path>...",数量是 brew 实际删除的文件数。
let count = line.dropFirst("Pruning ".count).prefix { $0 != " " }
report.removedCount += Int(count) ?? 1
}
if let bytes = try summarySize(line, prefix: "==> This operation has freed approximately ") {
report.freedBytes = bytes
}
}
static func parsePreview(_ output: String) throws -> ToolCleanupPreview {
var items: [CleanItem] = []
var details: [String] = []
@@ -8,6 +8,14 @@ import Localization
public struct ToolRunResult: Sendable {
public let status: Int32
public let output: String
/// 真实 Homebrew 清理的完整输出统计,避免截断诊断文本后丢失汇总。
public let cleanupReport: CleanReport?
public init(status: Int32, output: String, cleanupReport: CleanReport? = nil) {
self.status = status
self.output = output
self.cleanupReport = cleanupReport
}
}
public enum ToolExecutionError: Error, LocalizedError, Sendable {
@@ -42,7 +50,7 @@ enum DeveloperToolRunner {
/// 外部工具可能执行较久;放到并发执行器,避免阻塞调用它的主 Actor。
@concurrent
static func run(_ name: String, arguments: [String], home: String,
timeout: TimeInterval = 60, outputLimit: Int = 2_000_000) async throws -> ToolRunResult {
previewTimeout: TimeInterval = 60, outputLimit: Int = 2_000_000) async throws -> ToolRunResult {
try Task.checkCancellation()
guard let executable = executable(named: name, home: home) else {
throw ToolExecutionError.unavailable(name)
@@ -69,7 +77,11 @@ enum DeveloperToolRunner {
process.environment = environment
if name == "brew" {
return try await runHomebrew(process, timeout: timeout, outputLimit: outputLimit)
if arguments.first == "cleanup", !arguments.contains("--dry-run") {
// 真实清理可能已删除部分文件,不因超时或输出量中断;仍响应用户取消。
return try await runHomebrewCleanup(process, outputLimit: outputLimit)
}
return try await runHomebrewPreview(process, timeout: previewTimeout, outputLimit: outputLimit)
}
let pipe = Pipe()
@@ -91,8 +103,8 @@ enum DeveloperToolRunner {
return ToolRunResult(status: process.terminationStatus, output: output)
}
/// 预览需要完整输出才能解析。写临时文件避免管道堵塞,同时限制大小、执行时间并响应取消。
private static func runHomebrew(_ process: Process, timeout: TimeInterval, outputLimit: Int) async throws -> ToolRunResult {
/// 预览需要完整输出才能解析;超过限制时终止只读命令。
private static func runHomebrewPreview(_ process: Process, timeout: TimeInterval, outputLimit: Int) async throws -> ToolRunResult {
let outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("xstats-brew-\(UUID())")
guard FileManager.default.createFile(atPath: outputURL.path, contents: nil) else {
throw CocoaError(.fileWriteUnknown)
@@ -131,6 +143,80 @@ enum DeveloperToolRunner {
return ToolRunResult(status: process.terminationStatus, output: output)
}
/// 持续排空管道,让清理执行到底;只保留有限的末尾诊断文本和逐行汇总。
private static func runHomebrewCleanup(_ process: Process, outputLimit: Int) async throws -> ToolRunResult {
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
process.standardInput = FileHandle.nullDevice
try Task.checkCancellation()
do { try process.run() } catch {
throw ToolExecutionError.failed(tool: "brew", status: -1, output: error.localizedDescription)
}
try? pipe.fileHandleForWriting.close()
let reader = pipe.fileHandleForReading
let outputTask = Task.detached(priority: .utility) {
try readHomebrewCleanupOutput(reader, outputLimit: outputLimit)
}
defer {
stop(process)
try? reader.close()
}
while process.isRunning {
try Task.checkCancellation()
try await Task.sleep(for: .milliseconds(25))
}
try Task.checkCancellation()
let result = try await outputTask.value
guard process.terminationStatus == 0 else {
throw ToolExecutionError.failed(tool: "brew", status: process.terminationStatus,
output: String(result.output.suffix(2_000)))
}
guard !result.hasInvalidSummary else { throw HomebrewCleanup.PreviewError.unsupportedOutput }
return ToolRunResult(status: process.terminationStatus, output: result.output, cleanupReport: result.report)
}
private struct CleanupOutput: Sendable {
let output: String
let report: CleanReport
let hasInvalidSummary: Bool
}
private static func readHomebrewCleanupOutput(_ reader: FileHandle, outputLimit: Int) throws -> CleanupOutput {
var tail = Data()
var line = [UInt8]()
var lineTooLong = false
var report = CleanReport()
var hasInvalidSummary = false
while let chunk = try reader.read(upToCount: 64 * 1024), !chunk.isEmpty {
tail.append(chunk)
if tail.count > outputLimit { tail.removeFirst(tail.count - max(0, outputLimit)) }
for byte in chunk {
if byte == 0x0A {
if !lineTooLong { recordHomebrewLine(line, report: &report, hasInvalidSummary: &hasInvalidSummary) }
line.removeAll(keepingCapacity: true)
lineTooLong = false
} else if line.count < 64 * 1024 {
line.append(byte)
} else {
// 跳过异常长的单行诊断输出,继续读取后续的 Homebrew 汇总行。
lineTooLong = true
}
}
}
if !line.isEmpty, !lineTooLong {
recordHomebrewLine(line, report: &report, hasInvalidSummary: &hasInvalidSummary)
}
return CleanupOutput(output: String(decoding: tail, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines),
report: report, hasInvalidSummary: hasInvalidSummary)
}
private static func recordHomebrewLine(_ bytes: [UInt8], report: inout CleanReport, hasInvalidSummary: inout Bool) {
let line = String(decoding: bytes, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
do { try HomebrewCleanup.recordExecutionLine(line, report: &report) }
catch { hasInvalidSummary = true }
}
/// 只结束本次启动的进程;取消后仍给予短暂退出时间,防止清理子进程留在后台。
private static func stop(_ process: Process) {
guard process.isRunning else { return }
@@ -555,6 +555,8 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 ذاكرة حزم P
已连接 متصل
已连接的键盘、鼠标、耳机等电量低于 15%,每 10 分钟检查一次 بطارية لوحة مفاتيح أو ماوس أو سماعات متصلة دون 15%؛ تُفحص كل 10 دقائق
已选 {} 项 · {} تم تحديد {} · {}
释放量未确认 المساحة غير مؤكدة
部分结果未确认 بعض النتائج غير مؤكدة
已释放 تم تحريره
已释放 {} 缓存内存 حُرر {} من الذاكرة المؤقتة
布局 التخطيط
1 # Copyright (C) 2026 ysicing
555 已连接
556 已连接的键盘、鼠标、耳机等电量低于 15%,每 10 分钟检查一次
557 已选 {} 项 · {}
558 释放量未确认
559 部分结果未确认
560 已释放
561 已释放 {} 缓存内存
562 布局
@@ -555,6 +555,8 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Von uv geladener Py
已连接 Verbunden
已连接的键盘、鼠标、耳机等电量低于 15%,每 10 分钟检查一次 Batteriestand einer verbundenen Tastatur, Maus, Kopfhörer o. Ä. unter 15 %; Prüfung alle 10 Minuten
已选 {} 项 · {} {} ausgewählt · {}
释放量未确认 Speicher unbestätigt
部分结果未确认 Einige Ergebnisse sind nicht bestätigt
已释放 Freigegeben
已释放 {} 缓存内存 {} Cache-Arbeitsspeicher freigegeben
布局 Layout
1 # Copyright (C) 2026 ysicing
555 已连接
556 已连接的键盘、鼠标、耳机等电量低于 15%,每 10 分钟检查一次
557 已选 {} 项 · {}
558 释放量未确认
559 部分结果未确认
560 已释放
561 已释放 {} 缓存内存
562 布局
@@ -555,6 +555,8 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Cachés de paquetes
已连接 Conectado
已连接的键盘、鼠标、耳机等电量低于 15%,每 10 分钟检查一次 Batería de teclado, ratón, auriculares u otros dispositivos por debajo del 15%; se comprueba cada 10 minutos
已选 {} 项 · {} {} seleccionados · {}
释放量未确认 Espacio sin confirmar
部分结果未确认 Algunos resultados no están confirmados
已释放 Liberado
已释放 {} 缓存内存 Se liberaron {} de memoria caché
布局 Distribución
1 # Copyright (C) 2026 ysicing
555 已连接
556 已连接的键盘、鼠标、耳机等电量低于 15%,每 10 分钟检查一次
557 已选 {} 项 · {}
558 释放量未确认
559 部分结果未确认
560 已释放
561 已释放 {} 缓存内存
562 布局
@@ -555,6 +555,8 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Caches de paquets P
已连接 Connecté
已连接的键盘、鼠标、耳机等电量低于 15%,每 10 分钟检查一次 La batterie d’un clavier, d’une souris, d’écouteurs ou d’un autre appareil connecté passe sous 15 % ; vérification toutes les 10 minutes
已选 {} 项 · {} {} sélectionnés · {}
释放量未确认 Espace non confirmé
部分结果未确认 Certains résultats ne sont pas confirmés
已释放 Libéré
已释放 {} 缓存内存 {} de mémoire cache libérés
布局 Disposition
1 # Copyright (C) 2026 ysicing
555 已连接
556 已连接的键盘、鼠标、耳机等电量低于 15%,每 10 分钟检查一次
557 已选 {} 项 · {}
558 释放量未确认
559 部分结果未确认
560 已释放
561 已释放 {} 缓存内存
562 布局
@@ -555,6 +555,8 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uvのPythonパッ
已连接 接続済み
已连接的键盘、鼠标、耳机等电量低于 15%,每 10 分钟检查一次 接続中のキーボード、マウス、ヘッドフォンなどの残量が15%未満か10分ごとに確認
已选 {} 项 · {} {}項目選択済み · {}
释放量未确认 解放量は未確認
部分结果未确认 一部の結果は未確認
已释放 解放済み
已释放 {} 缓存内存 キャッシュメモリ{}を解放しました
布局 レイアウト
1 # Copyright (C) 2026 ysicing
555 已连接
556 已连接的键盘、鼠标、耳机等电量低于 15%,每 10 分钟检查一次
557 已选 {} 项 · {}
558 释放量未确认
559 部分结果未确认
560 已释放
561 已释放 {} 缓存内存
562 布局
@@ -555,6 +555,8 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uv의 Python 패키
已连接 연결됨
已连接的键盘、鼠标、耳机等电量低于 15%,每 10 分钟检查一次 연결된 키보드, 마우스, 헤드폰 등의 배터리가 15% 미만인지 10분마다 확인
已选 {} 项 · {} {}개 선택됨 · {}
释放量未确认 확보량 미확인
部分结果未确认 일부 결과를 확인할 수 없음
已释放 확보됨
已释放 {} 缓存内存 캐시 메모리 {}을(를) 확보했습니다
布局 레이아웃
1 # Copyright (C) 2026 ysicing
555 已连接
556 已连接的键盘、鼠标、耳机等电量低于 15%,每 10 分钟检查一次
557 已选 {} 项 · {}
558 释放量未确认
559 部分结果未确认
560 已释放
561 已释放 {} 缓存内存
562 布局
@@ -556,6 +556,8 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uv 下載的 Python
已连接 已連接
已连接的键盘、鼠标、耳机等电量低于 15%,每 10 分钟检查一次 已連接的鍵盤、鼠標、耳機等電量低於 15%,每 10 分鐘檢查一次
已选 {} 项 · {} 已選 {} 項 · {}
释放量未确认 釋放量未確認
部分结果未确认 部分結果未確認
已释放 已釋放
已释放 {} 缓存内存 已釋放 {} 緩存內存
布局 佈局
1 # Copyright (c) 2026 GiantAccel, LLC
556 已连接
557 已连接的键盘、鼠标、耳机等电量低于 15%,每 10 分钟检查一次
558 已选 {} 项 · {}
559 释放量未确认
560 部分结果未确认
561 已释放
562 已释放 {} 缓存内存
563 布局
@@ -672,6 +672,8 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Python package and
已连接 Connected
已连接的键盘、鼠标、耳机等电量低于 15%,每 10 分钟检查一次 A connected keyboard, mouse, headphones or similar drops below 15%; checked every 10 minutes
已选 {} 项 · {} {} selected · {}
释放量未确认 Space unconfirmed
部分结果未确认 Some results are unconfirmed
已释放 Freed
已释放 {} 缓存内存 Freed {} of cached memory
布局 Layout
@@ -116,7 +116,10 @@ private struct SummaryCard: View {
private var headline: String {
let cleaner = model.cleaner
if cleaner.phase == .idle { return tr("尚未扫描") }
if cleaner.phase == .finished { return Format.bytes(cleaner.report?.freedBytes ?? 0, base: .decimal) }
if cleaner.phase == .finished {
guard let report = cleaner.report else { return Format.bytes(UInt64(0), base: .decimal) }
return report.hasUncertainFreedBytes ? tr("释放量未确认") : Format.bytes(report.freedBytes, base: .decimal)
}
return cleaner.scans.isEmpty ? tr("扫描中") : Format.bytes(cleaner.totalBytes, base: .decimal)
}
@@ -129,7 +132,14 @@ private struct SummaryCard: View {
case .ready: return tr("可清理 · 已选 \(cleaner.selectedScans.count) 项")
case .finished:
guard let report = cleaner.report else { return "" }
var parts = [report.wasCancelled ? tr("已取消") : tr("已释放")]
var parts: [String]
if report.wasCancelled {
parts = [tr("已取消")]
} else if report.hasUncertainFreedBytes {
parts = [tr("部分结果未确认")]
} else {
parts = [tr("已释放")]
}
if report.trashedBytes > 0 { parts.append(tr("另有 \(Format.bytes(report.trashedBytes, base: .decimal)) 移到废纸篓")) }
if report.skippedCount > 0 { parts.append(tr("跳过 \(report.skippedCount) 项")) }
if !report.failures.isEmpty { parts.append(tr("\(report.failures.count) 项失败")) }
@@ -149,12 +149,22 @@ struct DeveloperCacheRuleTests {
#expect(scan.items.map(\.url) == [cellar])
#expect(scan.totalSize == 1_200_000)
#expect(scan.rule.usesToolCleaner)
let logURL = home.root.appendingPathComponent("cleanup.log")
let report = await CleanEngine.clean(scans, selected: [scan.id], preferTrash: true,
environment: environment, log: nil)
environment: environment, log: CleanLog(url: logURL))
#expect(report.freedBytes == 1_100_000)
#expect(report.removedCount == 1)
#expect(report.trashedBytes == 0)
#expect(report.failures.isEmpty)
let logData = try Data(contentsOf: logURL)
let entries = try logData.split(separator: 0x0A).map {
try #require(JSONSerialization.jsonObject(with: Data($0)) as? [String: Any])
}
#expect(entries.count == 1)
#expect(entries.first?["action"] as? String == "tool")
#expect(entries.first?["bytes"] as? Int == 1_100_000)
#expect(entries.first?["removedCount"] as? Int == 1)
#expect(entries.first?["path"] == nil)
#expect(FileManager.default.fileExists(atPath: cached.path))
let generic = try RuleCatalog.userCaches.locate(environment)
#expect(!generic.contains { $0.lastPathComponent == "Homebrew" })
@@ -47,6 +47,8 @@ import Testing
}
@Test func cleanupFailureKeepsNativeError() async throws {
let logURL = FileManager.default.temporaryDirectory.appendingPathComponent("xstats-brew-log-\(UUID())")
defer { try? FileManager.default.removeItem(at: logURL) }
let environment = CleanEnvironment(runningBundleIdentifiers: { [] }, isToolAvailable: { _ in true },
runTool: { _, arguments in
if arguments.contains("--dry-run") {
@@ -56,10 +58,19 @@ import Testing
})
let scans = await CleanEngine.scan([RuleCatalog.homebrewCache], environment: environment)
let report = await CleanEngine.clean(scans, selected: [RuleCatalog.homebrewCache.id], preferTrash: false,
environment: environment, log: nil)
environment: environment, log: CleanLog(url: logURL))
#expect(report.failures.count == 1)
#expect(report.failures.first?.contains("cannot acquire lock") == true)
#expect(report.freedBytes == 0)
#expect(report.hasUncertainFreedBytes)
let logData = try Data(contentsOf: logURL)
let entries = try logData.split(separator: 0x0A).map {
try #require(JSONSerialization.jsonObject(with: Data($0)) as? [String: Any])
}
#expect(entries.count == 1)
#expect(entries.first?["action"] as? String == "fail")
#expect(entries.first?["path"] == nil)
#expect(entries.first?["bytes"] == nil)
}
@Test func cancelledCleanupIsNotReportedAsAToolFailure() async {
@@ -76,6 +87,7 @@ import Testing
#expect(report.failures.isEmpty)
#expect(report.wasCancelled)
#expect(report.removedCount == 0)
#expect(report.hasUncertainFreedBytes)
}
@Test func brewRunnerSetsSafetyEnvironmentAndKeepsArguments() async throws {
@@ -93,20 +105,71 @@ import Testing
let home = try stubBrew("#!/bin/sh\nexec /bin/sleep 30\n")
defer { try? FileManager.default.removeItem(at: home) }
do {
_ = try await DeveloperToolRunner.run("brew", arguments: [], home: home.path, timeout: 0.1)
_ = try await DeveloperToolRunner.run("brew", arguments: ["--dry-run"], home: home.path, previewTimeout: 0.1)
Issue.record("超时命令不应成功")
} catch ToolExecutionError.timedOut { }
try "#!/bin/sh\nprintf '1234567890'\n".write(to: home.appendingPathComponent(".local/bin/brew"), atomically: false, encoding: .utf8)
do {
_ = try await DeveloperToolRunner.run("brew", arguments: [], home: home.path, outputLimit: 8)
_ = try await DeveloperToolRunner.run("brew", arguments: ["--dry-run"], home: home.path, outputLimit: 8)
Issue.record("超出限制的输出不应成功")
} catch ToolExecutionError.outputTooLarge { }
}
@Test func brewCleanupRunsToCompletionPastPreviewLimits() async throws {
let home = try stubBrew("""
#!/bin/sh
/bin/sleep 0.3
printf 'Removing: /tmp/old... (1MB)\\n'
i=0
while [ "$i" -lt 2500 ]; do
printf 'Warning: verbose output %d\\n' "$i"
i=$((i + 1))
done
printf 'Pruning 2 files from: /tmp/cache...\\n'
printf '==> This operation has freed approximately 2MB of disk space.\\n'
touch "$HOME/done"
""")
defer { try? FileManager.default.removeItem(at: home) }
let environment = CleanEnvironment(home: home.path, runningBundleIdentifiers: { [] },
isToolAvailable: { _ in true },
runTool: { tool, arguments in
try await DeveloperToolRunner.run(tool, arguments: arguments, home: home.path,
previewTimeout: 0.1, outputLimit: 64)
})
let report = try #require(try await HomebrewCleanup.clean(environment))
#expect(report.removedCount == 3)
#expect(report.freedBytes == 2_000_000)
#expect(FileManager.default.fileExists(atPath: home.appendingPathComponent("done").path), "真实清理不能因输出过大被中途结束")
}
@Test func cleanupCountsEveryPrunedFile() async throws {
let environment = CleanEnvironment(runningBundleIdentifiers: { [] }, isToolAvailable: { _ in true },
runTool: { _, arguments in
if arguments.contains("--dry-run") {
return ToolRunResult(status: 0, output: """
Would prune 57 files from: /opt/homebrew/cache/downloads
==> This operation would free approximately 2MB of disk space.
""")
}
return ToolRunResult(status: 0, output: """
Removing: /opt/homebrew/Cellar/tool/1... (1MB)
Pruning 57 files from: /opt/homebrew/cache/downloads...
Pruning 1 file from: /opt/homebrew/cache/api...
==> This operation has freed approximately 2MB of disk space.
""")
})
let scans = await CleanEngine.scan([RuleCatalog.homebrewCache], environment: environment)
let report = await CleanEngine.clean(scans, selected: [RuleCatalog.homebrewCache.id], preferTrash: false,
environment: environment, log: nil)
#expect(report.failures.isEmpty)
#expect(report.removedCount == 59)
#expect(report.freedBytes == 2_000_000)
}
@Test func brewRunnerCancellationStopsTheStartedProcess() async throws {
let home = try stubBrew("#!/bin/sh\necho $$ > \"$HOME/pid\"\nexec /bin/sleep 30\n")
defer { try? FileManager.default.removeItem(at: home) }
let task = Task { try await DeveloperToolRunner.run("brew", arguments: [], home: home.path) }
let task = Task { try await DeveloperToolRunner.run("brew", arguments: ["cleanup"], home: home.path) }
let deadline = ContinuousClock.now.advanced(by: .seconds(3))
let pidFile = home.appendingPathComponent("pid")
while !FileManager.default.fileExists(atPath: pidFile.path), ContinuousClock.now < deadline {