fix(cleaner): 修复 Homebrew 清理执行与统计
This commit is contained in:
1 parent
a8e1170aac
commit
f5ba9f88be
16 files changed
+253
-21
No files matched your search
+1
-1
@@ -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 دقائق
|
||||
已选 {} 项 · {} تم تحديد {} · {}
|
||||
释放量未确认 المساحة غير مؤكدة
|
||||
部分结果未确认 بعض النتائج غير مؤكدة
|
||||
已释放 تم تحريره
|
||||
已释放 {} 缓存内存 حُرر {} من الذاكرة المؤقتة
|
||||
布局 التخطيط
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -555,6 +555,8 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uvのPythonパッ
|
||||
已连接 接続済み
|
||||
已连接的键盘、鼠标、耳机等电量低于 15%,每 10 分钟检查一次 接続中のキーボード、マウス、ヘッドフォンなどの残量が15%未満か10分ごとに確認
|
||||
已选 {} 项 · {} {}項目選択済み · {}
|
||||
释放量未确认 解放量は未確認
|
||||
部分结果未确认 一部の結果は未確認
|
||||
已释放 解放済み
|
||||
已释放 {} 缓存内存 キャッシュメモリ{}を解放しました
|
||||
布局 レイアウト
|
||||
|
||||
|
@@ -555,6 +555,8 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uv의 Python 패키
|
||||
已连接 연결됨
|
||||
已连接的键盘、鼠标、耳机等电量低于 15%,每 10 分钟检查一次 연결된 키보드, 마우스, 헤드폰 등의 배터리가 15% 미만인지 10분마다 확인
|
||||
已选 {} 项 · {} {}개 선택됨 · {}
|
||||
释放量未确认 확보량 미확인
|
||||
部分结果未确认 일부 결과를 확인할 수 없음
|
||||
已释放 확보됨
|
||||
已释放 {} 缓存内存 캐시 메모리 {}을(를) 확보했습니다
|
||||
布局 레이아웃
|
||||
|
||||
|
@@ -556,6 +556,8 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uv 下載的 Python
|
||||
已连接 已連接
|
||||
已连接的键盘、鼠标、耳机等电量低于 15%,每 10 分钟检查一次 已連接的鍵盤、鼠標、耳機等電量低於 15%,每 10 分鐘檢查一次
|
||||
已选 {} 项 · {} 已選 {} 項 · {}
|
||||
释放量未确认 釋放量未確認
|
||||
部分结果未确认 部分結果未確認
|
||||
已释放 已釋放
|
||||
已释放 {} 缓存内存 已釋放 {} 緩存內存
|
||||
布局 佈局
|
||||
|
||||
|
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user